Viewing files at commit 2310099b696dCommit detailsView latest

Browse files
scripts/oakbench/agent_campaign.py 548 lines · 28.3 KB · python
1
"""Frozen local agent campaigns with durable, non-retrying trial journals.
2
3
The manifest attestation is an operator assertion, not an OS sandbox. Episode
4
wall budgets control admission and the next agent timeout; setup/oracle overhead
5
can exceed that budget. No dollar or provider-token ceiling is claimed.
6
"""
7
from __future__ import annotations
8
9
import argparse
10
import contextlib
11
from dataclasses import replace
12
from datetime import datetime, timezone
13
import fcntl
14
import hashlib
15
import json
16
import math
17
import os
18
from pathlib import Path
19
import random
20
import re
21
import time
22
import tempfile
23
from typing import Any
24
25
SCHEMA_VERSION = 1
26
WORKFLOWS = {"wide_config_refactor", "large_asset_manifest"}
27
LEVELS = {"zero-shot", "cheat-sheet", "full-docs"}
28
ROOT = Path(__file__).resolve().parents[2]
29
30
31
class CampaignError(ValueError):
32
    pass
33
34
35
def now() -> str:
36
    return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
37
38
39
def canonical(value: Any) -> bytes:
40
    return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
41
42
43
def digest(value: Any) -> str:
44
    return hashlib.sha256(canonical(value)).hexdigest()
45
46
47
def file_hash(path: Path) -> str:
48
    hasher = hashlib.sha256()
49
    with path.open("rb") as stream:
50
        for chunk in iter(lambda: stream.read(1024 * 1024), b""):
51
            hasher.update(chunk)
52
    return hasher.hexdigest()
53
54
55
def _unique_object(pairs):
56
    data = {}
57
    for key, value in pairs:
58
        if key in data:
59
            raise CampaignError("duplicate JSON key: " + key)
60
        data[key] = value
61
    return data
62
63
64
def read_json(path: Path) -> Any:
65
    try:
66
        return json.loads(path.read_text(), object_pairs_hook=_unique_object,
67
                          parse_constant=lambda value: (_ for _ in ()).throw(CampaignError("nonfinite JSON")))
68
    except (OSError, ValueError) as exc:
69
        raise CampaignError(f"cannot read {path}: {exc}") from exc
70
71
72
def _positive(value, name, integer=False):
73
    valid = type(value) in ((int,) if integer else (int, float))
74
    try:
75
        valid = valid and value > 0 and (integer or math.isfinite(value))
76
    except OverflowError:
77
        valid = False
78
    if not valid:
79
        raise CampaignError(name + " must be positive" + (" integer" if integer else " finite number"))
80
81
82
def _names(value, name, allowed=None):
83
    if not isinstance(value, list) or not value or any(not isinstance(x, str) or not x for x in value):
84
        raise CampaignError(name + " must be a nonempty string array")
85
    if len(set(value)) != len(value):
86
        raise CampaignError("duplicate " + name)
87
    if allowed is not None and not set(value) <= allowed:
88
        raise CampaignError("unsupported " + name + ": " + ", ".join(sorted(set(value) - allowed)))
89
90
91
def load_manifest(path: Path) -> dict:
92
    return validate_manifest(read_json(path), path.resolve().parent)
93
94
95
def validate_manifest(raw, base: Path) -> dict:
96
    required = {"schema_version", "campaign_id", "agents_config", "subjects_config", "agents", "subjects",
97
                "workflows", "instruction_levels", "repetitions", "seed", "max_trials", "max_wall_seconds",
98
                "trial_timeout_seconds", "track", "agent_environment"}
99
    if not isinstance(raw, dict) or not required <= set(raw) or set(raw) - required - {"isolation_attestation", "output_limit_bytes"}:
100
        raise CampaignError("manifest has missing or unknown fields")
101
    if type(raw["schema_version"]) is not int or raw["schema_version"] != SCHEMA_VERSION:
102
        raise CampaignError("unsupported campaign schema_version")
103
    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"]):
104
        raise CampaignError("invalid campaign_id")
105
    _names(raw["subjects"], "subjects")
106
    _names(raw["workflows"], "workflows", WORKFLOWS)
107
    _names(raw["instruction_levels"], "instruction_levels", LEVELS)
108
    if raw["track"] not in {"agent-default", "core-equivalent"} or raw["agent_environment"] != "minimal":
109
        raise CampaignError("campaigns require a supported track and minimal agent_environment")
110
    for key in ("repetitions", "max_trials"):
111
        _positive(raw[key], key, True)
112
    for key in ("trial_timeout_seconds", "max_wall_seconds"):
113
        _positive(raw[key], key)
114
    if type(raw["seed"]) is not int:
115
        raise CampaignError("seed must be an integer")
116
    if "output_limit_bytes" in raw:
117
        _positive(raw["output_limit_bytes"], "output_limit_bytes", True)
118
    agents = raw["agents"]
119
    if not isinstance(agents, list) or not agents:
120
        raise CampaignError("agents must be a nonempty array")
121
    for agent in agents:
122
        if not isinstance(agent, dict) or set(agent) != {"name", "model"}:
123
            raise CampaignError("each agent requires exactly name and model")
124
        if agent["model"] is not None and (not isinstance(agent["model"], str) or not agent["model"].strip()):
125
            raise CampaignError("model must be null or a nonempty string")
126
    _names([agent["name"] for agent in agents], "agents")
127
    for name in raw["subjects"] + [agent["name"] for agent in agents]:
128
        if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}", name):
129
            raise CampaignError("agent and subject names must be safe slugs")
130
    raw = dict(raw)
131
    for key in ("agents_config", "subjects_config"):
132
        if not isinstance(raw[key], str) or not raw[key]:
133
            raise CampaignError(key + " must be a path")
134
        raw[key] = str((base / Path(raw[key]).expanduser()).resolve())
135
    return raw
136
137
138
def schedule(manifest: dict) -> list[dict]:
139
    size = (len(manifest["subjects"]) * len(manifest["agents"]) * len(manifest["workflows"])
140
            * len(manifest["instruction_levels"]) * manifest["repetitions"])
141
    if size > manifest["max_trials"] or size > 10000:
142
        raise CampaignError(f"schedule has {size} trials; exceeds manifest or 10000-trial safety limit")
143
    rows = []
144
    rng = random.Random(manifest["seed"])
145
    groups = [(workflow, agent, level) for workflow in manifest["workflows"]
146
              for agent in manifest["agents"] for level in manifest["instruction_levels"]]
147
    for repetition in range(manifest["repetitions"]):
148
        block_order = list(groups)
149
        rng.shuffle(block_order)
150
        for workflow, agent, level in block_order:
151
            identity = {"campaign_id": manifest["campaign_id"], "workflow": workflow,
152
                        "agent": agent["name"], "model": agent["model"], "instruction_level": level}
153
            order = list(manifest["subjects"])
154
            random.Random(str(manifest["seed"]) + digest(identity)).shuffle(order)
155
            shift = repetition % len(order)
156
            order = order[shift:] + order[:shift]
157
            block = "block-" + digest({**identity, "repetition": repetition})[:24]
158
            for subject in order:
159
                rows.append({**identity, "subject": subject, "repetition": repetition,
160
                             "block_id": block, "trial_id": "trial-" + digest([block, subject])[:24],
161
                             "ordinal": len(rows)})
162
    if len(rows) > manifest["max_trials"]:
163
        raise CampaignError(f"schedule has {len(rows)} trials, exceeds max_trials={manifest['max_trials']}")
164
    return rows
165
166
167
def resolve_runtime(manifest: dict):
168
    import agent_workflow
169
    from oakbench.subjects import load_subjects, subject_details
170
    try:
171
        agents = agent_workflow.load_agents(Path(manifest["agents_config"]), ",".join(x["name"] for x in manifest["agents"]))
172
        subjects = load_subjects(argparse.Namespace(config=Path(manifest["subjects_config"]),
173
            subjects=",".join(manifest["subjects"]), git_bin=None, oak_installed_bin=None, oak_local_bin=None))
174
    except (SystemExit, KeyError, OSError) as exc:
175
        raise CampaignError("configuration preflight failed: " + str(exc)) from exc
176
    if {x.name for x in subjects} != set(manifest["subjects"]):
177
        raise CampaignError("unknown selected subject")
178
    requested = {x["name"]: x["model"] for x in manifest["agents"]}
179
    if {agent.name for agent in agents} != set(requested):
180
        raise CampaignError("selected agent names must identify exact configured entries")
181
    agents = [replace(agent, model=requested[agent.name]) for agent in agents]
182
    for agent in agents:
183
        if agent.adapter not in {"deterministic_mock", "codex_exec_json", "claude_print_stream_json", "cursor_agent_stream_json"}:
184
            raise CampaignError("unsupported agent adapter: " + agent.adapter)
185
        if agent.adapter == "deterministic_mock" and agent.model is not None:
186
            raise CampaignError("mock agents require model=null")
187
        if agent.adapter != "deterministic_mock" and not agent.model:
188
            raise CampaignError("real agents require an explicit requested model: " + agent.name)
189
    for subject in subjects:
190
        if subject.kind not in {"git", "oak"} or subject.git_mode is not None:
191
            raise CampaignError("only local Git/Oak subjects are supported")
192
    for binary in [x.bin for x in agents if x.bin is not None] + [x.bin for x in subjects]:
193
        if not binary.is_file() or not os.access(binary, os.X_OK):
194
            raise CampaignError("not an executable binary: " + str(binary))
195
    from oakbench.runlock import measurement_lock_path
196
    provenance = {
197
        "measurement_lock_path": str(measurement_lock_path()),
198
        "configs": {key: {"path": manifest[key], "sha256": file_hash(Path(manifest[key]))}
199
                    for key in ("agents_config", "subjects_config")},
200
        "subjects": subject_details(subjects),
201
        "agents": {agent.name: {"adapter": agent.adapter, "provider": agent.provider, "model": agent.model,
202
                    "version": agent.version, "bin": str(agent.bin.resolve()) if agent.bin else None,
203
                    "binary_sha256": file_hash(agent.bin) if agent.bin else None} for agent in agents},
204
        "harness": {str(path.relative_to(ROOT)): file_hash(path) for path in sorted((ROOT / "scripts").rglob("*.py"))},
205
        "configuration_and_prompts": {
206
            str(path.relative_to(ROOT)): file_hash(path)
207
            for directory in (ROOT / "config", ROOT / "scenarios")
208
            for path in sorted(directory.rglob("*")) if path.is_file() and not any(part.startswith(".") for part in path.relative_to(directory).parts)
209
        },
210
    }
211
    return {x.name: x for x in agents}, {x.name: x for x in subjects}, provenance
212
213
214
def safe_path(path: Path) -> Path:
215
    if ".." in path.parts:
216
        raise CampaignError("destination path traversal is not allowed")
217
    absolute = path.absolute()
218
    for item in [absolute, *absolute.parents]:
219
        if item.is_symlink() and not (str(item) in {"/tmp", "/var"} and str(item.resolve()) == "/private" + str(item)):
220
            raise CampaignError("destination symlink is not allowed: " + str(item))
221
    return absolute.resolve()
222
223
224
def artifact_hashes(trial_root: Path) -> dict[str, str]:
225
    candidates = list((trial_root / "artifacts").rglob("*"))
226
    candidates.extend((trial_root / "work").rglob("AGENTS.md"))
227
    result = {}
228
    for path in candidates:
229
        if path.is_symlink():
230
            continue
231
        if path.is_file():
232
            try:
233
                safe_path(path)
234
                result[path.relative_to(trial_root).as_posix()] = file_hash(path)
235
            except (OSError, CampaignError):
236
                continue
237
    return result
238
239
240
def _sync_directory(path):
241
    fd = os.open(path, os.O_RDONLY)
242
    try:
243
        os.fsync(fd)
244
    finally:
245
        os.close(fd)
246
247
248
def atomic_write(path: Path, content: bytes):
249
    safe_path(path)
250
    fd, name = tempfile.mkstemp(prefix=path.name + ".tmp-", dir=path.parent)
251
    temporary = Path(name)
252
    with os.fdopen(fd, "wb") as stream:
253
        stream.write(content)
254
        stream.flush()
255
        os.fsync(stream.fileno())
256
    os.replace(temporary, path)
257
    _sync_directory(path.parent)
258
259
260
def create_plan(manifest_path: Path, directory: Path) -> dict:
261
    manifest = load_manifest(manifest_path)
262
    trials = schedule(manifest)
263
    _, _, provenance = resolve_runtime(manifest)
264
    from oakbench.runner import runner_fields
265
    plan = {"schema_version": 1, "campaign_id": manifest["campaign_id"], "created_at": now(),
266
            "manifest": manifest, "provenance": provenance, "runner": runner_fields(), "schedule": trials,
267
            "budget_policy": "cumulative episode-wall admission; interrupted episodes reserve their timeout; setup/oracle overhead may exceed cap"}
268
    plan["plan_sha256"] = digest(plan)
269
    directory = safe_path(directory)
270
    if directory.exists():
271
        raise CampaignError("campaign directory already exists; refusing overwrite: " + str(directory))
272
    try:
273
        directory.mkdir(parents=True)
274
    except FileExistsError as exc:
275
        raise CampaignError("campaign directory collision") from exc
276
    atomic_write(directory / "plan.json", canonical(plan) + b"\n")
277
    return plan
278
279
280
def load_plan(directory: Path) -> dict:
281
    safe_path(directory / "plan.json")
282
    plan = read_json(directory / "plan.json")
283
    if not isinstance(plan, dict) or type(plan.get("schema_version")) is not int or plan.get("schema_version") != 1:
284
        raise CampaignError("invalid frozen plan")
285
    body = {key: value for key, value in plan.items() if key != "plan_sha256"}
286
    if digest(body) != plan.get("plan_sha256"):
287
        raise CampaignError("frozen plan fingerprint mismatch")
288
    try:
289
        for key in ("campaign_id", "manifest", "schedule", "provenance", "runner"):
290
            if key not in plan:
291
                raise CampaignError("invalid frozen plan: missing " + key)
292
        if not isinstance(plan["provenance"], dict) or not isinstance(plan["runner"], dict):
293
            raise CampaignError("invalid frozen plan metadata")
294
        manifest = validate_manifest(plan["manifest"], directory.resolve())
295
        if manifest != plan["manifest"] or plan["campaign_id"] != manifest["campaign_id"]:
296
            raise CampaignError("invalid frozen manifest identity or paths")
297
        if not all(isinstance(plan["runner"].get(key), str) for key in ("runner_id", "runner_class")):
298
            raise CampaignError("invalid frozen runner identity")
299
        if schedule(manifest) != plan["schedule"]:
300
            raise CampaignError("frozen schedule mismatch")
301
    except (KeyError, TypeError, AttributeError) as exc:
302
        raise CampaignError("invalid frozen plan structure") from exc
303
    return plan
304
305
306
def validate_trial_row(row: dict, trial: dict, plan: dict):
307
    import agent_workflow
308
    if not isinstance(row, dict) or agent_workflow.validate_row(row):
309
        raise CampaignError("invalid journal terminal row schema")
310
    expected = {"task_id": trial["trial_id"], "bench_id": plan["campaign_id"],
311
                "subject": trial["subject"], "scenario": trial["workflow"], "run": trial["repetition"]}
312
    if any(row.get(key) != value for key, value in expected.items()):
313
        raise CampaignError("terminal row does not match frozen trial identity")
314
    expected_agent = {"name": trial["agent"], "model": trial["model"],
315
                      "instruction_level": trial["instruction_level"],
316
                      "environment": plan["manifest"]["agent_environment"]}
317
    pinned_agent = plan["provenance"].get("agents", {}).get(trial["agent"], {})
318
    expected_agent.update({key: pinned_agent[key] for key in ("adapter", "provider", "version") if key in pinned_agent})
319
    if any(row["agent"].get(key) != value for key, value in expected_agent.items()):
320
        raise CampaignError("terminal row agent does not match frozen trial identity")
321
322
323
def load_events(directory: Path, plan: dict, *, tolerate_torn=False) -> list[dict]:
324
    path = safe_path(directory / "events.jsonl")
325
    if not path.exists():
326
        return []
327
    raw = path.read_bytes()
328
    tail = b""
329
    if raw and not raw.endswith(b"\n"):
330
        if not tolerate_torn:
331
            raise CampaignError("journal has incomplete final record; resume requires --acknowledge-torn-journal; affected trials are never retried")
332
        prefix_end = raw.rfind(b"\n") + 1
333
        raw, tail = raw[:prefix_end], raw[prefix_end:]
334
    events = []
335
    known = {trial["trial_id"]: trial for trial in plan["schedule"]}
336
    started, finished = set(), set()
337
    for line in raw.splitlines():
338
        try:
339
            event = json.loads(line, object_pairs_hook=_unique_object,
340
                parse_constant=lambda value: (_ for _ in ()).throw(CampaignError("nonfinite journal JSON")))
341
        except ValueError as exc:
342
            raise CampaignError("invalid journal record") from exc
343
        if not isinstance(event, dict):
344
            raise CampaignError("journal record must be an object")
345
        if event.get("event") == "journal_recovery":
346
            if event.get("plan_sha256") != plan["plan_sha256"] or not isinstance(event.get("tail_sha256"), str):
347
                raise CampaignError("invalid journal recovery marker")
348
            events.append(event)
349
            continue
350
        trial = event.get("trial_id")
351
        if not isinstance(trial, str):
352
            raise CampaignError("journal trial_id must be a string")
353
        if trial not in known or event.get("plan_sha256") != plan["plan_sha256"]:
354
            raise CampaignError("journal trial or plan mismatch")
355
        if event.get("event") == "started" and trial not in started:
356
            _positive(event.get("timeout_seconds"), "journal timeout_seconds")
357
            started.add(trial)
358
        elif event.get("event") == "finished" and trial in started and trial not in finished:
359
            duration = event.get("wall_seconds")
360
            if type(duration) not in (int, float) or not math.isfinite(duration) or duration < 0:
361
                raise CampaignError("journal wall_seconds must be finite and nonnegative")
362
            if event.get("outcome") not in {"pass", "fail", "timeout", "error"}:
363
                raise CampaignError("invalid journal terminal outcome")
364
            if event["outcome"] != "error" or event.get("row") is not None:
365
                row = event.get("row")
366
                validate_trial_row(row, known[trial], plan)
367
                if row.get("outcome") != event["outcome"]:
368
                    raise CampaignError("journal outcome contradicts trial row")
369
            finished.add(trial)
370
        else:
371
            raise CampaignError("duplicate or out-of-order journal event")
372
        events.append(event)
373
    if tail:
374
        # Only the most recent intact start can own this torn terminal.
375
        # Older interrupted trials do not account for a newly torn start.
376
        active = bool(events and events[-1].get("event") == "started")
377
        candidate = next((trial for trial in known if trial not in started), None)
378
        if not active and candidate:
379
            events.append({"event": "started", "trial_id": candidate, "plan_sha256": plan["plan_sha256"],
380
                           "timeout_seconds": plan["manifest"]["trial_timeout_seconds"], "uncertain_torn_start": True})
381
        events.append({"event": "journal_recovery", "plan_sha256": plan["plan_sha256"],
382
                       "tail_sha256": hashlib.sha256(tail).hexdigest(), "tail_bytes": len(tail),
383
                       "acknowledged": False})
384
    return events
385
386
387
def acknowledge_torn_journal(directory, plan):
388
    events = load_events(directory, plan, tolerate_torn=True)
389
    if not events or events[-1].get("event") != "journal_recovery" or events[-1].get("acknowledged") is not False:
390
        return events
391
    raw = (directory / "events.jsonl").read_bytes()
392
    archive = directory / ("events-before-recovery-" + hashlib.sha256(raw).hexdigest() + ".jsonl")
393
    if not archive.exists():
394
        atomic_write(archive, raw)
395
    elif safe_path(archive).read_bytes() != raw:
396
        raise CampaignError("recovery archive collision")
397
    events[-1].update(acknowledged=True, archive=archive.name, archive_sha256=hashlib.sha256(raw).hexdigest(), timestamp_utc=now())
398
    atomic_write(directory / "events.jsonl", b"".join(canonical(event) + b"\n" for event in events))
399
    return events
400
401
402
def append_event(directory: Path, plan: dict, event: dict):
403
    record = {**event, "plan_sha256": plan["plan_sha256"], "timestamp_utc": now()}
404
    with safe_path(directory / "events.jsonl").open("ab") as stream:
405
        stream.write(canonical(record) + b"\n")
406
        stream.flush()
407
        os.fsync(stream.fileno())
408
    _sync_directory(directory)
409
    return record
410
411
412
def campaign_status(plan: dict, events: list[dict]) -> dict:
413
    starts = {x["trial_id"]: x for x in events if x["event"] == "started"}
414
    finishes = {x["trial_id"]: x for x in events if x["event"] == "finished"}
415
    interrupted = sorted(set(starts) - set(finishes))
416
    charged = sum(float(x["wall_seconds"]) for x in finishes.values())
417
    charged += sum(float(starts[key]["timeout_seconds"]) for key in interrupted)
418
    return {"campaign_id": plan["campaign_id"], "plan_sha256": plan["plan_sha256"],
419
            "scheduled": len(plan["schedule"]), "finished": len(finishes),
420
            "journal_recoveries": [x for x in events if x["event"] == "journal_recovery"],
421
            "interrupted_trial_ids": interrupted,
422
            "pending_trial_ids": [x["trial_id"] for x in plan["schedule"] if x["trial_id"] not in starts],
423
            "charged_episode_seconds": charged,
424
            "remaining_episode_seconds": max(0.0, plan["manifest"]["max_wall_seconds"] - charged)}
425
426
427
@contextlib.contextmanager
428
def campaign_lock(directory):
429
    with safe_path(directory / ".run.lock").open("a") as stream:
430
        try:
431
            fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
432
        except BlockingIOError as exc:
433
            raise CampaignError("campaign already has an active runner") from exc
434
        try:
435
            yield
436
        finally:
437
            fcntl.flock(stream, fcntl.LOCK_UN)
438
439
440
def preflight(plan: dict, directory: Path, enable_real=False):
441
    agents, subjects, provenance = resolve_runtime(plan["manifest"])
442
    if provenance != plan["provenance"]:
443
        raise CampaignError("configuration, harness or binary provenance drift; create a new campaign")
444
    from oakbench.runner import runner_fields
445
    current = runner_fields()
446
    if any(current[key] != plan["runner"][key] for key in ("runner_id", "runner_class")):
447
        raise CampaignError("runner identity drift; create a new campaign")
448
    real = any(agent.adapter != "deterministic_mock" for agent in agents.values())
449
    if real:
450
        attestation = plan["manifest"].get("isolation_attestation")
451
        if not enable_real:
452
            raise CampaignError("real agents require --enable-real; no paid invocation has started")
453
        if not isinstance(attestation, dict) or set(attestation) != {"worker_root", "unrestricted_cli_acknowledged"}:
454
            raise CampaignError("real agents require isolated-worker attestation; it is not a sandbox")
455
        if attestation["unrestricted_cli_acknowledged"] is not True or not Path(attestation["worker_root"]).is_absolute():
456
            raise CampaignError("invalid isolated-worker attestation")
457
        if not directory.resolve().is_relative_to(Path(attestation["worker_root"]).resolve()):
458
            raise CampaignError("campaign directory is outside attested worker root")
459
    return agents, subjects
460
461
462
def run_campaign(directory: Path, *, resume=False, enable_real=False, limit=None, acknowledge_torn=False) -> dict:
463
    import agent_workflow
464
    import workflow_ab
465
    from oakbench.runlock import measurement_lock
466
    directory = safe_path(directory)
467
    plan = load_plan(directory)
468
    with campaign_lock(directory):
469
        agents, subjects = preflight(plan, directory, enable_real)
470
        events = acknowledge_torn_journal(directory, plan) if resume and acknowledge_torn else load_events(directory, plan)
471
        if events and not resume:
472
            raise CampaignError("campaign already started; use resume (interrupted trials are not retried)")
473
        if limit is not None:
474
            _positive(limit, "limit", True)
475
        initial_state = campaign_status(plan, events)
476
        pending = set(initial_state["pending_trial_ids"])
477
        remaining = initial_state["remaining_episode_seconds"]
478
        completed_now = 0
479
        stopped_reason = None
480
        rows = [event["row"] for event in events if event.get("row") is not None]
481
        atomic_write(directory / "rows.jsonl", b"".join(canonical(row) + b"\n" for row in rows))
482
        for trial in plan["schedule"]:
483
            if trial["trial_id"] not in pending:
484
                continue
485
            timeout = plan["manifest"]["trial_timeout_seconds"]
486
            if remaining < timeout:
487
                stopped_reason = "insufficient_budget_for_frozen_trial_timeout"
488
                break
489
            if limit is not None and completed_now >= limit:
490
                stopped_reason = "invocation_trial_limit"
491
                break
492
            trial_root = safe_path(directory / "trials" / trial["trial_id"])
493
            if trial_root.exists():
494
                raise CampaignError("trial path collision; refusing overwrite: " + str(trial_root))
495
            with measurement_lock("agent-campaign:" + trial["trial_id"]) as lock:
496
                if not lock.enabled:
497
                    raise CampaignError("global measurement lock is mandatory for campaigns")
498
                pinned_lock = plan["provenance"].get("measurement_lock_path")
499
                if pinned_lock is not None and lock.path != pinned_lock:
500
                    raise CampaignError("acquired measurement lock differs from frozen provenance")
501
                started = append_event(directory, plan, {"event": "started", "trial_id": trial["trial_id"],
502
                    "timeout_seconds": timeout, "ordinal": trial["ordinal"]})
503
                events.append(started)
504
                start = time.monotonic()
505
                print(f"[trial] {trial['ordinal'] + 1}/{len(plan['schedule'])} {trial['trial_id']} {trial['agent']}/{trial['subject']}", flush=True)
506
                row = None
507
                try:
508
                    trial_root.mkdir(parents=True)
509
                    workflow = workflow_ab.WORKFLOWS[trial["workflow"]]
510
                    fixture = workflow_ab.prepare_fixture(trial_root / "fixture", workflow)
511
                    row = agent_workflow.run_one(
512
                        agent=agents[trial["agent"]], subject=subjects[trial["subject"]], workflow=workflow,
513
                        fixture=fixture, run_index=trial["repetition"], run_root=trial_root / "work",
514
                        artifacts_root=trial_root / "artifacts", timeout_seconds=timeout,
515
                        track=plan["manifest"]["track"], agent_environment=plan["manifest"]["agent_environment"],
516
                        instruction_level=trial["instruction_level"], bench_id=plan["campaign_id"],
517
                        timestamp_utc=now(), source={}, host_meta={},
518
                        output_limit_bytes=plan["manifest"].get("output_limit_bytes", 8 * 1024 * 1024),
519
                    )
520
                    row["task_id"] = trial["trial_id"]
521
                    validate_trial_row(row, trial, plan)
522
                    terminal = {"event": "finished", "trial_id": trial["trial_id"], "outcome": row["outcome"], "row": row}
523
                except (Exception, SystemExit) as exc:
524
                    terminal = {"event": "finished", "trial_id": trial["trial_id"], "outcome": "error",
525
                                "error": type(exc).__name__ + ": " + str(exc)}
526
                    if row is not None:
527
                        try:
528
                            canonical(row)
529
                            terminal["rejected_row"] = row
530
                        except (ValueError, TypeError):
531
                            terminal["rejected_row_repr"] = repr(row)
532
                terminal["wall_seconds"] = time.monotonic() - start
533
                remaining = max(0.0, remaining - terminal["wall_seconds"])
534
                terminal["artifact_sha256"] = artifact_hashes(trial_root)
535
                terminal["artifact_integrity_source"] = "sha256_of_regular_trial_artifacts_and_instructions;links_and_missing_files_not_attested"
536
                terminal["artifact_integrity_status"] = "observed_files_only" if terminal["artifact_sha256"] else "unavailable"
537
                events.append(append_event(directory, plan, terminal))
538
                if terminal.get("row") is not None:
539
                    with safe_path(directory / "rows.jsonl").open("ab") as stream:
540
                        stream.write(canonical(terminal["row"]) + b"\n")
541
                        stream.flush()
542
                        os.fsync(stream.fileno())
543
                completed_now += 1
544
        # The journal is authoritative. A crash after its final fsync may leave
545
        # derived rows absent/stale even when there are no pending trials.
546
        rows = [event["row"] for event in events if event.get("row") is not None]
547
        atomic_write(directory / "rows.jsonl", b"".join(canonical(row) + b"\n" for row in rows))
548
        return {**campaign_status(plan, events), "stopped_reason": stopped_reason}