Viewing files at commit fcd8a785ee1aCommit detailsView latest

Browse files
tests/test_agent_campaign.py 454 lines · 24.6 KB · python
1
"""Campaign contracts and transaction boundaries; no paid agent invocations."""
2
from __future__ import annotations
3
import copy
4
import json
5
import os
6
from pathlib import Path
7
import sys
8
import tempfile
9
import unittest
10
from unittest import mock
11
12
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
13
from oakbench import agent_campaign as campaign
14
15
16
class CampaignTest(unittest.TestCase):
17
    def setUp(self):
18
        self.tmp = tempfile.TemporaryDirectory()
19
        self.root = Path(self.tmp.name).resolve()
20
        self.manifest_path = self.root / "manifest.json"
21
        self.raw = json.loads((campaign.ROOT / "config/agent-campaign-smoke.json").read_text())
22
        self.raw.update(agents_config=str(campaign.ROOT / "config/agents.toml"),
23
                        subjects_config=str(campaign.ROOT / "config/subjects.toml"))
24
        self.write_manifest()
25
26
    def tearDown(self):
27
        self.tmp.cleanup()
28
29
    def write_manifest(self):
30
        self.manifest_path.write_text(json.dumps(self.raw))
31
32
    def manifest(self):
33
        self.write_manifest()
34
        return campaign.load_manifest(self.manifest_path)
35
36
    def plan(self):
37
        with mock.patch.object(campaign, "resolve_runtime", return_value=({}, {}, {})):
38
            return campaign.create_plan(self.manifest_path, self.root / "campaign")
39
40
    def test_schedule_determinism_balance_and_identity(self):
41
        first = campaign.schedule(self.manifest())
42
        self.assertEqual(first, campaign.schedule(self.manifest()))
43
        self.assertEqual(len({trial["trial_id"] for trial in first}), len(first))
44
        self.assertEqual([x["subject"] for x in first[:2]], list(reversed([x["subject"] for x in first[2:]])))
45
        self.assertEqual(first[0]["block_id"], first[1]["block_id"])
46
        self.assertNotEqual(first[0]["block_id"], first[2]["block_id"])
47
        self.raw["instruction_levels"] = ["full-docs", "zero-shot"]
48
        self.raw["max_trials"] = 8
49
        self.assertEqual(len(campaign.schedule(self.manifest())), 8)
50
51
    def test_invalid_limits_unknown_fields_duplicates_and_paths(self):
52
        for key, value in (("schema_version", True), ("repetitions", True), ("repetitions", 0),
53
                           ("max_trials", -1), ("max_wall_seconds", float("nan")),
54
                           ("trial_timeout_seconds", float("inf")), ("seed", True),
55
                           ("subjects", ["git", "git"]), ("subjects", ["../escape"]),
56
                           ("workflows", ["history_archaeology"]), ("typo", 1)):
57
            with self.subTest(key=key, value=value):
58
                raw = dict(self.raw)
59
                raw[key] = value
60
                self.manifest_path.write_text(json.dumps(raw))
61
                with self.assertRaises(campaign.CampaignError):
62
                    campaign.load_manifest(self.manifest_path)
63
        self.manifest_path.write_text('{"schema_version":1,"schema_version":1}')
64
        with self.assertRaises(campaign.CampaignError):
65
            campaign.load_manifest(self.manifest_path)
66
67
    def test_huge_schedule_rejected_before_materialization(self):
68
        self.raw["repetitions"] = 10 ** 30
69
        with self.assertRaises(campaign.CampaignError):
70
            campaign.schedule(self.manifest())
71
72
    def test_freeze_collision_and_tamper_rejected(self):
73
        plan = self.plan()
74
        directory = self.root / "campaign"
75
        self.assertEqual(campaign.load_plan(directory), plan)
76
        with mock.patch.object(campaign, "resolve_runtime", return_value=({}, {}, {})):
77
            with self.assertRaises(campaign.CampaignError):
78
                campaign.create_plan(self.manifest_path, directory)
79
        plan["schedule"][0]["subject"] = "wrong"
80
        (directory / "plan.json").write_text(json.dumps(plan))
81
        with self.assertRaises(campaign.CampaignError):
82
            campaign.load_plan(directory)
83
84
    def test_interrupted_trial_is_never_pending_and_reserves_budget(self):
85
        plan = self.plan()
86
        directory = self.root / "campaign"
87
        trial = plan["schedule"][0]["trial_id"]
88
        campaign.append_event(directory, plan, {"event": "started", "trial_id": trial, "timeout_seconds": 60})
89
        status = campaign.campaign_status(plan, campaign.load_events(directory, plan))
90
        self.assertEqual(status["interrupted_trial_ids"], [trial])
91
        self.assertNotIn(trial, status["pending_trial_ids"])
92
        self.assertEqual(status["charged_episode_seconds"], 60)
93
94
    def test_torn_duplicate_unknown_nonfinite_and_bad_duration_journal_fail_closed(self):
95
        plan = self.plan()
96
        directory = self.root / "campaign"
97
        trial = plan["schedule"][0]["trial_id"]
98
        start = {"event": "started", "trial_id": trial, "plan_sha256": plan["plan_sha256"], "timeout_seconds": 60}
99
        variants = [b'{"event":', b'[]\n', (json.dumps(start) + '\n') .encode() * 2,
100
                    (json.dumps({**start, "trial_id": "other"}) + '\n').encode(),
101
                    (json.dumps({**start, "timeout_seconds": float("nan")}) + '\n').encode()]
102
        for wall in (True, -1, float("inf")):
103
            finished = {"event": "finished", "trial_id": trial, "plan_sha256": plan["plan_sha256"],
104
                        "outcome": "error", "error": "test", "wall_seconds": wall}
105
            variants.append((json.dumps(start) + '\n' + json.dumps(finished) + '\n').encode())
106
        for content in variants:
107
            with self.subTest(content=content):
108
                (directory / "events.jsonl").write_bytes(content)
109
                with self.assertRaises(campaign.CampaignError):
110
                    campaign.load_events(directory, plan)
111
                self.assertEqual((directory / "events.jsonl").read_bytes(), content)
112
113
    def test_torn_new_start_after_old_interruption_reserves_next_trial(self):
114
        plan = self.plan()
115
        directory = self.root / "campaign"
116
        first, second, third = plan["schedule"][:3]
117
        for trial in (first, second):
118
            campaign.append_event(directory, plan, {"event": "started", "trial_id": trial["trial_id"], "timeout_seconds": 60})
119
        campaign.append_event(directory, plan, {"event": "finished", "trial_id": second["trial_id"],
120
            "outcome": "error", "wall_seconds": 1})
121
        with (directory / "events.jsonl").open("ab") as stream:
122
            stream.write(b'{"event":"started"')
123
        events = campaign.load_events(directory, plan, tolerate_torn=True)
124
        self.assertEqual(events[-2]["trial_id"], third["trial_id"])
125
        self.assertTrue(events[-2]["uncertain_torn_start"])
126
        state = campaign.campaign_status(plan, events)
127
        self.assertNotIn(third["trial_id"], state["pending_trial_ids"])
128
129
    def test_system_tmp_alias_is_accepted_but_arbitrary_symlinks_are_not(self):
130
        self.assertEqual(campaign.safe_path(Path("/tmp") / "campaign-safe-path-check"),
131
                         (Path("/tmp") / "campaign-safe-path-check").resolve())
132
133
    def test_terminal_row_identity_cannot_be_rebound(self):
134
        from agent_metrics_schema import example_row
135
        plan = self.plan()
136
        directory = self.root / "campaign"
137
        trial = plan["schedule"][0]
138
        row = example_row()
139
        row.update(task_id=trial["trial_id"], bench_id=plan["campaign_id"], subject=trial["subject"],
140
                   scenario=trial["workflow"], run=trial["repetition"], outcome="pass")
141
        row["agent"].update(name=trial["agent"], model=trial["model"],
142
                             instruction_level=trial["instruction_level"], environment="minimal")
143
        campaign.validate_trial_row(row, trial, plan)
144
        for key, value in (("subject", "wrong"), ("scenario", "wrong"), ("run", 999), ("bench_id", "other")):
145
            wrong = copy.deepcopy(row)
146
            wrong[key] = value
147
            with self.subTest(key=key), self.assertRaises(campaign.CampaignError):
148
                campaign.validate_trial_row(wrong, trial, plan)
149
        for key in ("name", "model", "instruction_level"):
150
            wrong = copy.deepcopy(row)
151
            wrong["agent"][key] = "wrong"
152
            with self.subTest(agent_key=key), self.assertRaises(campaign.CampaignError):
153
                campaign.validate_trial_row(wrong, trial, plan)
154
        campaign.append_event(directory, plan, {"event": "started", "trial_id": trial["trial_id"], "timeout_seconds": 60})
155
        row["subject"] = "wrong"
156
        campaign.append_event(directory, plan, {"event": "finished", "trial_id": trial["trial_id"],
157
            "outcome": "pass", "row": row, "wall_seconds": 1})
158
        with self.assertRaises(campaign.CampaignError):
159
            campaign.load_events(directory, plan)
160
161
    def test_symlink_destinations_and_lock_rejected(self):
162
        alias = self.root / "alias"
163
        alias.symlink_to(self.root, target_is_directory=True)
164
        with self.assertRaises(campaign.CampaignError):
165
            campaign.safe_path(alias / "campaign")
166
        plan = self.plan()
167
        directory = self.root / "campaign"
168
        for name in ("events.jsonl", ".run.lock", "trials"):
169
            path = directory / name
170
            path.symlink_to(self.root / "outside")
171
            with self.assertRaises(campaign.CampaignError):
172
                campaign.safe_path(path)
173
            path.unlink()
174
175
    def test_exclusive_runner_ownership(self):
176
        self.plan()
177
        with campaign.campaign_lock(self.root / "campaign"):
178
            with self.assertRaises(campaign.CampaignError):
179
                with campaign.campaign_lock(self.root / "campaign"):
180
                    pass
181
182
    def test_preflight_drift_real_optin_and_attestation(self):
183
        plan = self.plan()
184
        directory = self.root / "campaign"
185
        with mock.patch.object(campaign, "resolve_runtime", return_value=({}, {}, {"changed": True})):
186
            with self.assertRaises(campaign.CampaignError):
187
                campaign.preflight(plan, directory)
188
        from agent_workflow import AgentConfig
189
        real = AgentConfig("real", "codex_exec_json", "openai", Path("/binary"), "pinned", "1")
190
        with mock.patch.object(campaign, "resolve_runtime", return_value=({"real": real}, {}, {})):
191
            with self.assertRaises(campaign.CampaignError):
192
                campaign.preflight(plan, directory)
193
            with self.assertRaises(campaign.CampaignError):
194
                campaign.preflight(plan, directory, enable_real=True)
195
            plan["manifest"]["isolation_attestation"] = {
196
                "worker_root": str(self.root), "unrestricted_cli_acknowledged": True}
197
            self.assertEqual(campaign.preflight(plan, directory, enable_real=True)[0]["real"], real)
198
199
    def test_lock_provenance_drift_names_the_key(self):
200
        plan = self.plan()
201
        plan["provenance"] = {"measurement_lock_path": "/old/lock"}
202
        with mock.patch.object(campaign, "resolve_runtime", return_value=({}, {}, {"measurement_lock_path": "/new/lock"})):
203
            with self.assertRaisesRegex(campaign.CampaignError, "measurement_lock_path"):
204
                campaign.preflight(plan, self.root / "campaign")
205
206
    def test_fixture_cache_reuses_generation_and_rejects_mutated_payload(self):
207
        import workflow_ab
208
        from dataclasses import replace
209
        counter = mock.Mock(side_effect=workflow_ab.WORKFLOWS["wide_config_refactor"].make_fixture)
210
        workflow = replace(workflow_ab.WORKFLOWS["wide_config_refactor"], make_fixture=counter)
211
        plan = self.plan()
212
        directory = self.root / "campaign"
213
        fixture = campaign.cached_fixture(directory, plan, workflow)
214
        self.assertEqual(fixture, directory / "fixtures" / workflow.name)
215
        self.assertEqual(campaign.cached_fixture(directory, plan, workflow), fixture)
216
        self.assertEqual(counter.call_count, 1)
217
        payload = next(fixture.glob("services/*/config.toml"))
218
        payload.chmod(0o600)
219
        payload.write_text("corrupt fixture")
220
        with self.assertRaisesRegex(campaign.CampaignError, "fixture.*drift"):
221
            campaign.cached_fixture(directory, plan, workflow)
222
        self.assertEqual(counter.call_count, 1)
223
224
    def test_corrupted_cache_aborts_before_any_trial_admission(self):
225
        import workflow_ab
226
        plan = self.plan()
227
        directory = self.root / "campaign"
228
        fixture = campaign.cached_fixture(directory, plan, workflow_ab.WORKFLOWS["wide_config_refactor"])
229
        payload = next(fixture.glob("services/*/config.toml"))
230
        payload.chmod(0o600)
231
        payload.write_text("bad cached baseline")
232
        with mock.patch.object(campaign, "preflight", return_value=({}, {})), mock.patch("agent_workflow.run_one") as run:
233
            with self.assertRaisesRegex(campaign.CampaignError, "fixture.*drift"):
234
                campaign.run_campaign(directory, resume=True)
235
        run.assert_not_called()
236
        self.assertFalse((directory / "events.jsonl").exists())
237
        self.assertEqual(len(campaign.campaign_status(plan, [])['pending_trial_ids']), 4)
238
239
    def test_generation_crash_and_incomplete_cache_preserve_all_pending_trials(self):
240
        import workflow_ab
241
        plan = self.plan()
242
        directory = self.root / "campaign"
243
        def crash(root, workflow):
244
            path = root / "fixtures" / workflow.name
245
            path.mkdir(parents=True)
246
            (path / "partial").write_text("interrupted generation")
247
            raise KeyboardInterrupt("crash before fixture integrity record")
248
        with mock.patch.object(campaign, "preflight", return_value=({}, {})), mock.patch.object(workflow_ab, "prepare_fixture", side_effect=crash):
249
            with self.assertRaises(KeyboardInterrupt):
250
                campaign.run_campaign(directory)
251
        self.assertFalse((directory / "events.jsonl").exists())
252
        with mock.patch.object(campaign, "preflight", return_value=({}, {})):
253
            with self.assertRaisesRegex(campaign.CampaignError, "fixture cache is incomplete"):
254
                campaign.run_campaign(directory, resume=True)
255
        self.assertFalse((directory / "events.jsonl").exists())
256
        self.assertEqual(len(campaign.campaign_status(plan, [])['pending_trial_ids']), 4)
257
258
    def test_real_git_mock_cache_poisoning_invalidates_attempt_and_preserves_next(self):
259
        import agent_workflow
260
        import workflow_ab
261
        import shutil
262
        if not shutil.which("git"):
263
            self.skipTest("Git required")
264
        self.raw.update(subjects=["git"], repetitions=2)
265
        self.write_manifest()
266
        plan = self.plan()
267
        directory = self.root / "campaign"
268
        agent = agent_workflow.AgentConfig("mock", "deterministic_mock", "local", None, None, "1")
269
        subject = workflow_ab.Subject("git", "git", "Git", Path(shutil.which("git")))
270
        original = agent_workflow.run_mock_agent
271
        def poison(*args, **kwargs):
272
            execution = original(*args, **kwargs)
273
            payload = next((directory / "fixtures" / "wide_config_refactor").glob("services/*/config.toml"))
274
            payload.chmod(0o600)  # Same-uid agent can defeat read-only permissions.
275
            payload.write_text("poisoned shared baseline")
276
            return execution
277
        with mock.patch.object(campaign, "preflight", return_value=({"mock": agent}, {"git": subject})), \
278
             mock.patch.object(agent_workflow, "run_mock_agent", side_effect=poison) as execute:
279
            state = campaign.run_campaign(directory)
280
        self.assertEqual(execute.call_count, 1)
281
        self.assertEqual(len(state["pending_trial_ids"]), 1)
282
        self.assertEqual(state["stopped_reason"], "fixture_integrity_drift")
283
        events = campaign.load_events(directory, plan)
284
        self.assertEqual(len(events), 2)
285
        self.assertTrue(events[-1]["measurement_invalidated"])
286
        self.assertTrue(events[-1]["rejected_row"]["success"])
287
        self.assertNotIn("row", events[-1])
288
        self.assertEqual((directory / "rows.jsonl").read_bytes(), b"")
289
        from oakbench.campaign_report import report_campaign
290
        report = report_campaign(plan, events)
291
        self.assertEqual(report["counts"]["skipped"], 1)
292
        self.assertEqual(report["counts"]["unstarted"], 1)
293
294
    def test_copied_fixture_race_is_rejected_before_real_agent_launch(self):
295
        import agent_workflow
296
        import workflow_ab
297
        import shutil
298
        if not shutil.which("git"):
299
            self.skipTest("Git required")
300
        self.raw.update(subjects=["git"], repetitions=2)
301
        self.write_manifest()
302
        plan = self.plan()
303
        directory = self.root / "campaign"
304
        agent = agent_workflow.AgentConfig("mock", "deterministic_mock", "local", None, None, "1")
305
        subject = workflow_ab.Subject("git", "git", "Git", Path(shutil.which("git")))
306
        original = agent_workflow.copy_fixture
307
        def corrupt_copy(src, dest):
308
            original(src, dest)
309
            payload = next(dest.glob("services/*/config.toml"))
310
            payload.chmod(0o600)
311
            payload.write_text("changed during copy")
312
        with mock.patch.object(campaign, "preflight", return_value=({"mock": agent}, {"git": subject})), \
313
             mock.patch.object(agent_workflow, "copy_fixture", side_effect=corrupt_copy), \
314
             mock.patch.object(agent_workflow, "run_mock_agent") as execute:
315
            state = campaign.run_campaign(directory)
316
        execute.assert_not_called()
317
        self.assertEqual(len(state["pending_trial_ids"]), 1)
318
        self.assertTrue(campaign.load_events(directory, plan)[-1]["measurement_invalidated"])
319
320
    def test_journal_fixture_binding_rejects_missing_or_conflicting_attempt_hashes(self):
321
        plan = self.plan()
322
        trial = plan["schedule"][0]
323
        record = {"manifest": {"config": {"sha256": "a" * 64}}}
324
        fingerprint = campaign.digest(record["manifest"])
325
        start = {"event": "started", "trial_id": trial["trial_id"], "fixture_manifest_sha256": fingerprint}
326
        finish = {"event": "finished", "trial_id": trial["trial_id"], "fixture_manifest_sha256": fingerprint}
327
        campaign.verify_journal_fixture_binding(trial["workflow"], record, plan, [start, finish])
328
        for value in (None, "b" * 64):
329
            for index in (0, 1):
330
                events = [dict(start), dict(finish)]
331
                events[index]["fixture_manifest_sha256"] = value
332
                with self.assertRaisesRegex(campaign.CampaignError, "journal fixture binding"):
333
                    campaign.verify_journal_fixture_binding(trial["workflow"], record, plan, events)
334
335
    def test_completed_infrastructure_error_not_retried_on_resume(self):
336
        self.plan()
337
        directory = self.root / "campaign"
338
        import agent_workflow
339
        import workflow_ab
340
        from oakbench.runlock import LockInfo
341
        from contextlib import contextmanager
342
        @contextmanager
343
        def locked(label):
344
            yield LockInfo(0, True)
345
        agent = agent_workflow.AgentConfig("mock", "deterministic_mock", "local", None, None, "1")
346
        subject = workflow_ab.Subject("git", "git", "Git", Path("/git"))
347
        with mock.patch.object(campaign, "preflight", return_value=({"mock": agent}, {"git": subject, "oak_installed": subject})), \
348
             mock.patch("oakbench.runlock.measurement_lock", locked), \
349
             mock.patch.object(agent_workflow, "run_one", side_effect=RuntimeError("fixture setup failed")) as run:
350
            state = campaign.run_campaign(directory, limit=1)
351
            self.assertEqual(state["finished"], 1)
352
            campaign.run_campaign(directory, resume=True, limit=1)
353
            self.assertEqual(run.call_count, 2)
354
            events = campaign.load_events(directory, campaign.load_plan(directory))
355
            self.assertEqual(len({event["trial_id"] for event in events if event["event"] == "started"}), 2)
356
            self.assertEqual([event["outcome"] for event in events if event["event"] == "finished"], ["error", "error"])
357
358
    def test_mkdir_crash_after_durable_start_is_not_pending(self):
359
        from contextlib import contextmanager
360
        from oakbench.runlock import LockInfo
361
        self.plan()
362
        directory = self.root / "campaign"
363
        @contextmanager
364
        def locked(label):
365
            yield LockInfo(0, True)
366
        original = Path.mkdir
367
        def crash(path, *args, **kwargs):
368
            if path.parent.name == "trials":
369
                raise KeyboardInterrupt("simulated process crash")
370
            return original(path, *args, **kwargs)
371
        with mock.patch.object(campaign, "preflight", return_value=({}, {})), \
372
             mock.patch("oakbench.runlock.measurement_lock", locked), \
373
             mock.patch.object(Path, "mkdir", crash):
374
            with self.assertRaises(KeyboardInterrupt):
375
                campaign.run_campaign(directory)
376
        plan = campaign.load_plan(directory)
377
        events = campaign.load_events(directory, plan)
378
        self.assertEqual(len(events), 1)
379
        state = campaign.campaign_status(plan, events)
380
        self.assertEqual(state["interrupted_trial_ids"], [plan["schedule"][0]["trial_id"]])
381
        self.assertNotIn(plan["schedule"][0]["trial_id"], state["pending_trial_ids"])
382
383
    def test_schema_rejected_row_remains_in_terminal_evidence(self):
384
        import agent_workflow
385
        from contextlib import contextmanager
386
        from oakbench.runlock import LockInfo
387
        plan = self.plan()
388
        directory = self.root / "campaign"
389
        @contextmanager
390
        def locked(label):
391
            yield LockInfo(0, True)
392
        subjects = {trial["subject"]: object() for trial in plan["schedule"]}
393
        with mock.patch.object(campaign, "preflight", return_value=({"mock": object()}, subjects)), \
394
             mock.patch("oakbench.runlock.measurement_lock", locked), \
395
             mock.patch.object(agent_workflow, "run_one", return_value={"invalid": "original evidence"}):
396
            campaign.run_campaign(directory, limit=1)
397
        events = campaign.load_events(directory, plan)
398
        self.assertEqual(events[1]["outcome"], "error")
399
        self.assertEqual(events[1]["rejected_row"]["invalid"], "original evidence")
400
        self.assertEqual((directory / "rows.jsonl").read_bytes(), b"")
401
402
    def test_noop_resume_repairs_missing_or_stale_rows_from_finished_journal(self):
403
        from agent_metrics_schema import example_row
404
        self.raw.update(subjects=["git"], repetitions=1)
405
        self.write_manifest()
406
        plan = self.plan()
407
        directory = self.root / "campaign"
408
        trial = plan["schedule"][0]
409
        import workflow_ab
410
        campaign.cached_fixture(directory, plan, workflow_ab.WORKFLOWS[trial["workflow"]])
411
        record = campaign.read_json(directory / "fixture-integrity" / (trial["workflow"] + ".json"))
412
        fingerprint = campaign.digest(record["manifest"])
413
        row = example_row()
414
        row.update(task_id=trial["trial_id"], bench_id=plan["campaign_id"], subject=trial["subject"],
415
                   scenario=trial["workflow"], run=trial["repetition"], outcome="pass")
416
        row["agent"].update(name=trial["agent"], model=trial["model"],
417
                             instruction_level=trial["instruction_level"], environment="minimal")
418
        campaign.append_event(directory, plan, {"event": "started", "trial_id": trial["trial_id"], "timeout_seconds": 60, "fixture_manifest_sha256": fingerprint})
419
        campaign.append_event(directory, plan, {"event": "finished", "trial_id": trial["trial_id"],
420
            "outcome": "pass", "row": row, "wall_seconds": 1, "fixture_manifest_sha256": fingerprint})
421
        journal = (directory / "events.jsonl").read_bytes()
422
        for state in ("missing", "stale"):
423
            with self.subTest(state=state):
424
                if state == "stale":
425
                    (directory / "rows.jsonl").write_bytes(b"stale output")
426
                with mock.patch.object(campaign, "preflight", return_value=({}, {})), \
427
                     mock.patch("agent_workflow.run_one") as run:
428
                    status = campaign.run_campaign(directory, resume=True)
429
                    run.assert_not_called()
430
                self.assertEqual(status["finished"], 1)
431
                self.assertEqual((directory / "rows.jsonl").read_bytes(), campaign.canonical(row) + b"\n")
432
                self.assertEqual((directory / "events.jsonl").read_bytes(), journal)
433
434
    def test_disabled_measurement_lock_starts_nothing(self):
435
        self.plan()
436
        directory = self.root / "campaign"
437
        with mock.patch.object(campaign, "preflight", return_value=({}, {})), \
438
             mock.patch.dict(os.environ, {"OAK_BENCH_LOCK": "off"}):
439
            with self.assertRaises(campaign.CampaignError):
440
                campaign.run_campaign(directory)
441
        self.assertFalse((directory / "events.jsonl").exists())
442
443
    def test_artifact_hashes_exclude_links(self):
444
        trial = self.root / "trial"
445
        (trial / "artifacts").mkdir(parents=True)
446
        (trial / "artifacts/prompt.txt").write_text("fixed prompt")
447
        (trial / "artifacts/escape").symlink_to(self.manifest_path)
448
        hashes = campaign.artifact_hashes(trial)
449
        self.assertEqual(set(hashes), {"artifacts/prompt.txt"})
450
        self.assertEqual(hashes["artifacts/prompt.txt"], campaign.file_hash(trial / "artifacts/prompt.txt"))
451
452
453
if __name__ == "__main__":
454
    unittest.main()