pluginbench 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pluginbench/__init__.py +3 -0
- pluginbench/__main__.py +3 -0
- pluginbench/assessment-charts.html +790 -0
- pluginbench/assessment.py +316 -0
- pluginbench/assessment_charts.py +1225 -0
- pluginbench/assessment_metrics.py +1956 -0
- pluginbench/assessment_models.py +127 -0
- pluginbench/baselines.py +792 -0
- pluginbench/claude.py +374 -0
- pluginbench/cli.py +672 -0
- pluginbench/code_diff.py +207 -0
- pluginbench/codex.py +451 -0
- pluginbench/codex_events.py +585 -0
- pluginbench/collectors.py +591 -0
- pluginbench/compare.py +927 -0
- pluginbench/components.py +129 -0
- pluginbench/coverage_report.py +610 -0
- pluginbench/datasets.py +207 -0
- pluginbench/evaluation.example.yaml +45 -0
- pluginbench/evaluation.py +1651 -0
- pluginbench/evaluation_report.py +1004 -0
- pluginbench/fallow_parse.mjs +101 -0
- pluginbench/harbor_backend.py +2946 -0
- pluginbench/isolation.py +314 -0
- pluginbench/judge.py +683 -0
- pluginbench/language_analysis.py +915 -0
- pluginbench/ledger.py +217 -0
- pluginbench/measurement_profiles.py +616 -0
- pluginbench/native_treatment_runtime.js +221 -0
- pluginbench/native_treatments.py +155 -0
- pluginbench/observer_data/go-analysis-v1/go.mod +10 -0
- pluginbench/observer_data/go-analysis-v1/go.sum +10 -0
- pluginbench/observer_data/go-analysis-v1/main.go +64 -0
- pluginbench/observer_data/oxc-parser-0.126.0/package-lock.json +449 -0
- pluginbench/observer_data/oxc-parser-0.126.0/package.json +5 -0
- pluginbench/parallel.py +101 -0
- pluginbench/processes.py +100 -0
- pluginbench/remeasure.py +992 -0
- pluginbench/run_lock.py +227 -0
- pluginbench/run_status.py +518 -0
- pluginbench/study.py +1858 -0
- pluginbench/study_review.py +924 -0
- pluginbench/submissions.py +486 -0
- pluginbench/task_sources.py +403 -0
- pluginbench/treatments.py +388 -0
- pluginbench/trial_containers.py +94 -0
- pluginbench/vendor/ECHARTS-LICENSE +222 -0
- pluginbench/vendor/ECHARTS-NOTICE +5 -0
- pluginbench/vendor/ZRENDER-LICENSE +29 -0
- pluginbench/vendor/echarts.min.js +48 -0
- pluginbench-0.5.0.dist-info/METADATA +294 -0
- pluginbench-0.5.0.dist-info/RECORD +58 -0
- pluginbench-0.5.0.dist-info/WHEEL +4 -0
- pluginbench-0.5.0.dist-info/entry_points.txt +2 -0
- pluginbench-0.5.0.dist-info/licenses/LICENSE +201 -0
- pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-LICENSE +222 -0
- pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-NOTICE +5 -0
- pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ZRENDER-LICENSE +29 -0
pluginbench/baselines.py
ADDED
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
"""Reuse complete isolated baseline cohorts, never their observer results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import fcntl
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import tarfile
|
|
12
|
+
import tempfile
|
|
13
|
+
import tomllib
|
|
14
|
+
from contextlib import ExitStack
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from pluginbench import components, harbor_backend, submissions
|
|
19
|
+
from pluginbench.codex_events import failed_before_first_turn
|
|
20
|
+
from pluginbench.native_treatments import DELIVERY_POLICY, NativeCapabilities
|
|
21
|
+
from pluginbench.assessment_metrics import inspect_snapshot
|
|
22
|
+
from pluginbench.assessment_models import AttemptInput
|
|
23
|
+
from pluginbench.datasets import TaskSpec, native_identity, portable_native_identity, task_digest
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _hash(value: object) -> str:
|
|
27
|
+
return hashlib.sha256(
|
|
28
|
+
json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
|
29
|
+
).hexdigest()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _inventory(root: Path) -> dict[str, dict[str, object]]:
|
|
33
|
+
files: dict[str, dict[str, object]] = {}
|
|
34
|
+
for path in sorted(root.rglob("*")):
|
|
35
|
+
if path.is_symlink() or not (path.is_file() or path.is_dir()):
|
|
36
|
+
raise ValueError(f"baseline cache contains an unsupported artifact: {path}")
|
|
37
|
+
if path == root / "manifest.json":
|
|
38
|
+
continue
|
|
39
|
+
files[path.relative_to(root).as_posix()] = {
|
|
40
|
+
"mode": path.stat().st_mode & 0o777,
|
|
41
|
+
"sha256": hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None,
|
|
42
|
+
}
|
|
43
|
+
return files
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _copy_attempt(source: Path, target: Path) -> None:
|
|
47
|
+
def ignored(directory: str, names: list[str]) -> list[str]:
|
|
48
|
+
return [
|
|
49
|
+
name
|
|
50
|
+
for name in names
|
|
51
|
+
if name == "unsupported-submission"
|
|
52
|
+
and Path(directory).parent == source
|
|
53
|
+
and (Path(directory) / "unsupported-submission.json").is_file()
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
shutil.copytree(source, target, ignore=ignored, symlinks=True)
|
|
57
|
+
for stage in source.iterdir():
|
|
58
|
+
raw = stage / "unsupported-submission"
|
|
59
|
+
if raw.is_dir():
|
|
60
|
+
# Keep unsupported link evidence inert; never extract or follow its targets.
|
|
61
|
+
with tarfile.open(target / stage.name / "unsupported-submission.tar", "w") as archive:
|
|
62
|
+
archive.add(raw, arcname="unsupported-submission", recursive=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _docker_executable(value: object) -> dict[str, object] | None:
|
|
66
|
+
if not isinstance(value, dict):
|
|
67
|
+
return None
|
|
68
|
+
if not all(
|
|
69
|
+
isinstance(value.get(key), str) and Path(value[key]).is_absolute()
|
|
70
|
+
for key in ("path", "resolved_path")
|
|
71
|
+
):
|
|
72
|
+
return None
|
|
73
|
+
digest, version = value.get("sha256"), value.get("version")
|
|
74
|
+
if (
|
|
75
|
+
not isinstance(digest, str)
|
|
76
|
+
or not re.fullmatch(r"[a-f0-9]{64}", digest)
|
|
77
|
+
or not isinstance(version, str)
|
|
78
|
+
or not version
|
|
79
|
+
):
|
|
80
|
+
return None
|
|
81
|
+
return {key: field for key, field in value.items() if key != "path"}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _matches_docker_client(artifacts: Path, expected: dict[str, object]) -> bool:
|
|
85
|
+
try:
|
|
86
|
+
actual = json.loads((artifacts / "docker-client.json").read_text())
|
|
87
|
+
except (OSError, ValueError):
|
|
88
|
+
return False
|
|
89
|
+
if not isinstance(actual, dict):
|
|
90
|
+
return False
|
|
91
|
+
cli = _docker_executable(expected.get("docker_cli"))
|
|
92
|
+
if cli is None or _docker_executable(actual.get("docker_cli")) != cli:
|
|
93
|
+
return False
|
|
94
|
+
plugins, observed = expected.get("docker_plugins"), actual.get("docker_plugins")
|
|
95
|
+
if (
|
|
96
|
+
not isinstance(plugins, dict)
|
|
97
|
+
or not isinstance(observed, dict)
|
|
98
|
+
or set(plugins) != {"compose", "buildx"}
|
|
99
|
+
or set(observed) != set(plugins)
|
|
100
|
+
):
|
|
101
|
+
return False
|
|
102
|
+
for name, identity in plugins.items():
|
|
103
|
+
binary = _docker_executable(identity)
|
|
104
|
+
if binary is None or _docker_executable(observed[name]) != binary:
|
|
105
|
+
return False
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _eligible(*, attempt: Path, record: dict[str, Any], coding: object, task: TaskSpec) -> bool:
|
|
110
|
+
"""Admission binds actual native source, private runtime and cleanup receipts."""
|
|
111
|
+
if not isinstance(coding, dict):
|
|
112
|
+
return False
|
|
113
|
+
expected = coding.get("isolation")
|
|
114
|
+
identity = coding.get("native_task_identity")
|
|
115
|
+
settings = coding.get("study")
|
|
116
|
+
if (
|
|
117
|
+
not isinstance(expected, dict)
|
|
118
|
+
or not isinstance(identity, dict)
|
|
119
|
+
or not isinstance(settings, dict)
|
|
120
|
+
):
|
|
121
|
+
return False
|
|
122
|
+
image = identity.get("image_identity")
|
|
123
|
+
toolchain = identity.get("toolchain_identity")
|
|
124
|
+
if (
|
|
125
|
+
not isinstance(image, dict)
|
|
126
|
+
or not isinstance(toolchain, dict)
|
|
127
|
+
or toolchain.get("image_id") != image.get("image_id")
|
|
128
|
+
or toolchain.get("harness") != settings.get("harness")
|
|
129
|
+
or toolchain.get("harness_version") != coding.get("harness_version")
|
|
130
|
+
or not isinstance(identity.get("isolation"), dict)
|
|
131
|
+
or harbor_backend.runtime_contract(identity["isolation"])
|
|
132
|
+
!= harbor_backend.runtime_contract(expected)
|
|
133
|
+
):
|
|
134
|
+
return False
|
|
135
|
+
runtime = record.get("runtime") or {}
|
|
136
|
+
for actual, requested in (
|
|
137
|
+
(record.get("model"), settings.get("model")),
|
|
138
|
+
(runtime.get("harness"), str(settings.get("harness")) + "-cli"),
|
|
139
|
+
(runtime.get("reasoning_effort"), settings.get("reasoning")),
|
|
140
|
+
(runtime.get("harness_version"), coding.get("harness_version")),
|
|
141
|
+
):
|
|
142
|
+
if actual is not None and actual != requested:
|
|
143
|
+
return False
|
|
144
|
+
try:
|
|
145
|
+
return all(
|
|
146
|
+
_matches_native_stage(
|
|
147
|
+
target=attempt / stage["id"], stage=stage, coding=coding, task=task
|
|
148
|
+
)
|
|
149
|
+
for stage in record["stages"]
|
|
150
|
+
)
|
|
151
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _matches_treatment_delivery(
|
|
156
|
+
*, artifacts: Path, invocation: dict[str, Any], execution: dict[str, Any],
|
|
157
|
+
coding: dict[str, Any],
|
|
158
|
+
) -> bool:
|
|
159
|
+
"""Bind a native vanilla cache entry to its installed policy and empty treatment."""
|
|
160
|
+
if "delivery" not in coding:
|
|
161
|
+
return True # Historical cache identities cannot equal a native identity.
|
|
162
|
+
if coding["delivery"] != DELIVERY_POLICY:
|
|
163
|
+
return False
|
|
164
|
+
inventory: dict[str, list[object]] = {"guidance": [], "skills": [], "plugins": []}
|
|
165
|
+
if (invocation.get("delivery") != DELIVERY_POLICY
|
|
166
|
+
or invocation.get("capabilities") != coding.get("capabilities")
|
|
167
|
+
or invocation.get("treatment_inventory") != inventory
|
|
168
|
+
or (artifacts / "prompt.txt").read_bytes() != b""):
|
|
169
|
+
return False
|
|
170
|
+
receipt = json.loads((artifacts / "native-treatment.json").read_text())
|
|
171
|
+
if (not isinstance(receipt, dict) or receipt.get("status") != "verified"
|
|
172
|
+
or receipt.get("delivery") != DELIVERY_POLICY
|
|
173
|
+
or receipt.get("inventory") != inventory
|
|
174
|
+
or execution.get("native_treatment") != receipt):
|
|
175
|
+
return False
|
|
176
|
+
configuration = receipt.get("effective_config")
|
|
177
|
+
if not isinstance(configuration, str):
|
|
178
|
+
return False
|
|
179
|
+
actual = tomllib.loads(configuration)
|
|
180
|
+
policy = NativeCapabilities.model_validate(coding.get("capabilities"))
|
|
181
|
+
expected = policy.codex_config(
|
|
182
|
+
model=coding["study"]["model"], effort=coding["study"]["reasoning"],
|
|
183
|
+
)
|
|
184
|
+
for key, value in expected.items():
|
|
185
|
+
if isinstance(value, dict):
|
|
186
|
+
observed = actual.get(key)
|
|
187
|
+
if not isinstance(observed, dict) or any(
|
|
188
|
+
observed.get(setting) != required for setting, required in value.items()
|
|
189
|
+
):
|
|
190
|
+
return False
|
|
191
|
+
elif actual.get(key) != value:
|
|
192
|
+
return False
|
|
193
|
+
return True
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _matches_native_stage(
|
|
197
|
+
*, target: Path, stage: dict[str, Any], coding: dict[str, Any], task: TaskSpec
|
|
198
|
+
) -> bool:
|
|
199
|
+
identity, expected, settings = (
|
|
200
|
+
coding["native_task_identity"],
|
|
201
|
+
coding["isolation"],
|
|
202
|
+
coding["study"],
|
|
203
|
+
)
|
|
204
|
+
artifacts = target / "artifacts"
|
|
205
|
+
invocation = json.loads((artifacts / "invocation.json").read_text())
|
|
206
|
+
# Attempts record the runtime they ran with; entries from before that ran with the cohort's.
|
|
207
|
+
ran_with = invocation.get("runtime", expected)
|
|
208
|
+
if harbor_backend.runtime_contract(ran_with) != harbor_backend.runtime_contract(expected):
|
|
209
|
+
return False
|
|
210
|
+
if not _matches_docker_client(artifacts, ran_with):
|
|
211
|
+
return False
|
|
212
|
+
observed_identity = invocation.get("native_task_identity")
|
|
213
|
+
if (
|
|
214
|
+
not isinstance(observed_identity, dict)
|
|
215
|
+
or portable_native_identity(observed_identity) != identity
|
|
216
|
+
):
|
|
217
|
+
return False
|
|
218
|
+
if any(
|
|
219
|
+
invocation.get(key) != value
|
|
220
|
+
for key, value in {
|
|
221
|
+
"backend": harbor_backend.PROTOCOL,
|
|
222
|
+
"harness": str(settings.get("harness")) + "-cli",
|
|
223
|
+
"harness_version": coding.get("harness_version"),
|
|
224
|
+
"requested_model": settings.get("model"),
|
|
225
|
+
"requested_effort": settings.get("reasoning"),
|
|
226
|
+
"timeout_seconds": settings.get("timeout_seconds", coding.get("timeout_seconds")),
|
|
227
|
+
"task_assets_sha256": identity.get("task_assets_sha256"),
|
|
228
|
+
}.items()
|
|
229
|
+
):
|
|
230
|
+
return False
|
|
231
|
+
execution = json.loads((target / "execution.json").read_text())
|
|
232
|
+
if not _matches_treatment_delivery(
|
|
233
|
+
artifacts=artifacts, invocation=invocation, execution=execution, coding=coding,
|
|
234
|
+
):
|
|
235
|
+
return False
|
|
236
|
+
if execution.get("private_runtime_removed") is not True:
|
|
237
|
+
return False
|
|
238
|
+
for key, requested in (
|
|
239
|
+
("actual_primary_models", settings.get("model")),
|
|
240
|
+
("runtime_effort_evidence", settings.get("reasoning")),
|
|
241
|
+
):
|
|
242
|
+
values = execution.get(key, [])
|
|
243
|
+
if not isinstance(values, list) or any(value != requested for value in values):
|
|
244
|
+
return False
|
|
245
|
+
receipt = json.loads((artifacts / "runtime-isolation.json").read_text())
|
|
246
|
+
before = json.loads((artifacts / "before-source.json").read_text())
|
|
247
|
+
exported = submissions.validate_export(
|
|
248
|
+
diagnostic=target / "diagnostic-source", artifacts=artifacts, execution=execution
|
|
249
|
+
)
|
|
250
|
+
if (
|
|
251
|
+
receipt.get("export") != exported
|
|
252
|
+
or not _native_export_matches(before, identity, identity.get("source_digest"))
|
|
253
|
+
or not _native_export_matches(exported, identity, exported["snapshot_digest"])
|
|
254
|
+
):
|
|
255
|
+
return False
|
|
256
|
+
if before.get("container_id") != exported.get("container_id") or before.get(
|
|
257
|
+
"project"
|
|
258
|
+
) != exported.get("project"):
|
|
259
|
+
return False
|
|
260
|
+
if execution.get("native_verifier_result") is not None and not _native_grader_matches(
|
|
261
|
+
receipt.get("verifier"), identity
|
|
262
|
+
):
|
|
263
|
+
return False
|
|
264
|
+
if stage.get("snapshot"):
|
|
265
|
+
source = submissions.validate(
|
|
266
|
+
before=task.fixture,
|
|
267
|
+
task_dir=task.native_task,
|
|
268
|
+
identity=native_identity(task),
|
|
269
|
+
artifacts=artifacts,
|
|
270
|
+
execution=execution,
|
|
271
|
+
snapshot=target / "snapshot",
|
|
272
|
+
)
|
|
273
|
+
if source["snapshot_digest"] != stage["snapshot"]["sha256"]:
|
|
274
|
+
return False
|
|
275
|
+
else:
|
|
276
|
+
failure = json.loads((target / "unsupported-submission.json").read_text())
|
|
277
|
+
if failure.get("source_policy") != submissions.SOURCE_POLICY:
|
|
278
|
+
return False
|
|
279
|
+
with tempfile.TemporaryDirectory(prefix="pluginbench-cache-source-") as temporary:
|
|
280
|
+
try:
|
|
281
|
+
submissions.reconstruct(
|
|
282
|
+
before=task.fixture,
|
|
283
|
+
task_dir=task.native_task,
|
|
284
|
+
identity=native_identity(task),
|
|
285
|
+
artifacts=artifacts,
|
|
286
|
+
execution=execution,
|
|
287
|
+
destination=Path(temporary).resolve() / "source",
|
|
288
|
+
)
|
|
289
|
+
except ValueError:
|
|
290
|
+
pass
|
|
291
|
+
else:
|
|
292
|
+
return False
|
|
293
|
+
return _native_cleanup_matches(receipt.get("cleanup"))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _native_grader_matches(verifier: object, identity: dict[str, Any]) -> bool:
|
|
297
|
+
if not isinstance(verifier, dict):
|
|
298
|
+
return False
|
|
299
|
+
if verifier.get("image_id") != identity["grader_image_identity"].get("image_id"):
|
|
300
|
+
return False
|
|
301
|
+
harbor_backend.stopped_container_state(verifier.get("state"))
|
|
302
|
+
limits = identity["isolation"]["resources"]
|
|
303
|
+
resources = verifier.get("resources")
|
|
304
|
+
if not isinstance(resources, dict):
|
|
305
|
+
return False
|
|
306
|
+
return bool(
|
|
307
|
+
resources.get("NanoCpus") == limits["cpus"] * 1_000_000_000
|
|
308
|
+
and resources.get("Memory") == limits["memory_mb"] * 1024 * 1024
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _native_cleanup_matches(cleanup: object) -> bool:
|
|
313
|
+
if not isinstance(cleanup, dict) or cleanup.get("status") != "complete":
|
|
314
|
+
return False
|
|
315
|
+
projects = cleanup.get("projects")
|
|
316
|
+
if not isinstance(projects, dict) or not projects:
|
|
317
|
+
return False
|
|
318
|
+
return all(
|
|
319
|
+
isinstance(project, dict)
|
|
320
|
+
and project.get("status") == "complete"
|
|
321
|
+
and project.get("residuals") == {"containers": [], "networks": [], "images": []}
|
|
322
|
+
for project in projects.values()
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _native_export_matches(value: object, identity: dict[str, Any], digest: object) -> bool:
|
|
327
|
+
if not isinstance(value, dict) or not isinstance(identity.get("image_identity"), dict):
|
|
328
|
+
return False
|
|
329
|
+
image = identity["image_identity"]
|
|
330
|
+
if (
|
|
331
|
+
value.get("status") != "complete"
|
|
332
|
+
or value.get("stopped") is not True
|
|
333
|
+
or value.get("snapshot_digest") != digest
|
|
334
|
+
or not isinstance(digest, str)
|
|
335
|
+
or value.get("image_id") != image.get("image_id")
|
|
336
|
+
or value.get("workdir") != identity.get("workdir")
|
|
337
|
+
or value.get("resources")
|
|
338
|
+
!= {key: identity["isolation"]["resources"][key] for key in ("cpus", "memory_mb")}
|
|
339
|
+
or not isinstance(value.get("container_id"), str)
|
|
340
|
+
or not re.fullmatch(r"[a-f0-9]{64}", value["container_id"])
|
|
341
|
+
or not isinstance(value.get("project"), str)
|
|
342
|
+
or not re.fullmatch(r"pluginbench-[a-f0-9]{32}__env", value["project"])
|
|
343
|
+
):
|
|
344
|
+
return False
|
|
345
|
+
harbor_backend.stopped_container_state(value.get("state"))
|
|
346
|
+
return True
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def cohort_contexts(
|
|
350
|
+
*, tasks: tuple[TaskSpec, ...], entries: list[dict[str, Any]], identity: dict[str, object]
|
|
351
|
+
) -> dict[str, dict[str, Any]]:
|
|
352
|
+
"""Each task's vanilla cohort context; its hash is the cache key. Holds no run paths."""
|
|
353
|
+
contexts = {}
|
|
354
|
+
for task in tasks:
|
|
355
|
+
coding = {
|
|
356
|
+
key: value.get(task.task_id)
|
|
357
|
+
if key in {"task_assets", "task_environment"} and isinstance(value, dict)
|
|
358
|
+
else value
|
|
359
|
+
for key, value in identity.items()
|
|
360
|
+
}
|
|
361
|
+
coding["native_task_identity"] = portable_native_identity(native_identity(task))
|
|
362
|
+
contexts[task.task_id] = {
|
|
363
|
+
# v4 caches each repeat in its own entry under the cohort key.
|
|
364
|
+
"protocol": "native-harbor-baseline-cohort-v4",
|
|
365
|
+
"source_policy": submissions.SOURCE_POLICY,
|
|
366
|
+
"coding": coding,
|
|
367
|
+
"task_id": task.task_id,
|
|
368
|
+
"task_digest": task.digest,
|
|
369
|
+
"prompt": task.prompt,
|
|
370
|
+
"fixture_digest": inspect_snapshot(task.fixture)["digest"],
|
|
371
|
+
"repeats": sorted(
|
|
372
|
+
entry["attempt"]
|
|
373
|
+
for entry in entries
|
|
374
|
+
if entry["arm"] == "baseline" and entry["task_id"] == task.task_id
|
|
375
|
+
),
|
|
376
|
+
}
|
|
377
|
+
return contexts
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _cohort_contract(context: dict[str, Any]) -> dict[str, Any]:
|
|
381
|
+
"""What a cached cohort must share with this run: its context with runtimes as contracts.
|
|
382
|
+
|
|
383
|
+
Runtime recorded and fit fields and launcher source hashes are left out, and saved
|
|
384
|
+
hashes become their launcher version. The task digest covers the full native identity,
|
|
385
|
+
runtime included, so it is left out too; `_same_cohort` checks it separately.
|
|
386
|
+
"""
|
|
387
|
+
contract = copy.deepcopy({key: value for key, value in context.items() if key != "task_digest"})
|
|
388
|
+
coding = contract["coding"]
|
|
389
|
+
coding["isolation"] = harbor_backend.runtime_contract(coding["isolation"])
|
|
390
|
+
for key in ("native_task_identity", "task_environment"):
|
|
391
|
+
if isinstance(coding.get(key), dict) and "isolation" in coding[key]:
|
|
392
|
+
coding[key]["isolation"] = harbor_backend.runtime_contract(coding[key]["isolation"])
|
|
393
|
+
hashes = coding.pop("launcher_sha256", None)
|
|
394
|
+
if hashes is not None and "launcher_version" not in coding:
|
|
395
|
+
study = coding.get("study")
|
|
396
|
+
coding["launcher_version"] = components.saved_launcher_version(
|
|
397
|
+
study.get("harness") if isinstance(study, dict) else None, hashes
|
|
398
|
+
)
|
|
399
|
+
return contract
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _cohort_key(context: dict[str, Any], task: TaskSpec) -> str:
|
|
403
|
+
contract = _cohort_contract(context)
|
|
404
|
+
contract["task_digest"] = task_digest(task, contract["coding"]["native_task_identity"])
|
|
405
|
+
return _hash(contract)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _same_cohort(saved: object, contract: dict[str, Any], task: TaskSpec) -> bool:
|
|
409
|
+
"""A saved context matches when its contract is equal and its own task digest still holds.
|
|
410
|
+
|
|
411
|
+
Recomputing the digest from the saved native identity binds prompt, source, file modes and
|
|
412
|
+
provenance exactly. Anything unreadable, or saved by a build this one cannot vouch for,
|
|
413
|
+
does not match.
|
|
414
|
+
"""
|
|
415
|
+
if not isinstance(saved, dict):
|
|
416
|
+
return False
|
|
417
|
+
try:
|
|
418
|
+
return bool(
|
|
419
|
+
_cohort_contract(saved) == contract
|
|
420
|
+
and task_digest(task, saved["coding"]["native_task_identity"]) == saved["task_digest"]
|
|
421
|
+
)
|
|
422
|
+
except (KeyError, TypeError, ValueError):
|
|
423
|
+
return False
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _saved_match(entry: Path, contract: dict[str, Any], task: TaskSpec) -> bool:
|
|
427
|
+
"""A saved `<key>/<repeat>` entry whose manifest is readable and whose context matches."""
|
|
428
|
+
try:
|
|
429
|
+
context = json.loads((entry / "manifest.json").read_text())["context"]
|
|
430
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
431
|
+
return False
|
|
432
|
+
return _same_cohort(context, contract, task)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _saved_cohort(cohort: Path, contract: dict[str, Any], task: TaskSpec) -> bool:
|
|
436
|
+
"""A saved cohort directory with any matching repeat; one that cannot be listed has none."""
|
|
437
|
+
try:
|
|
438
|
+
entries = sorted(cohort.iterdir())
|
|
439
|
+
except OSError:
|
|
440
|
+
return False
|
|
441
|
+
return any(_saved_match(entry, contract, task) for entry in entries)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _cohorts(path: Path, key: str, contract: dict[str, Any], task: TaskSpec) -> list[Path]:
|
|
445
|
+
"""Directories that may hold this task's repeats: its own key, then saved cohorts.
|
|
446
|
+
|
|
447
|
+
A saved cohort, such as one an older PluginBench keyed by its full runtime, qualifies
|
|
448
|
+
when any of its repeats matches; its name then is that older key.
|
|
449
|
+
"""
|
|
450
|
+
saved = [
|
|
451
|
+
cohort
|
|
452
|
+
for cohort in (sorted(path.iterdir()) if path.is_dir() else [])
|
|
453
|
+
if cohort.name != key
|
|
454
|
+
and not cohort.name.startswith(".")
|
|
455
|
+
and not cohort.is_symlink()
|
|
456
|
+
and cohort.is_dir()
|
|
457
|
+
and _saved_cohort(cohort, contract, task)
|
|
458
|
+
]
|
|
459
|
+
return [path / key, *saved]
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _resolve(
|
|
463
|
+
cohorts: list[Path], repeat: int, contract: dict[str, Any], task: TaskSpec
|
|
464
|
+
) -> Path | None:
|
|
465
|
+
"""Where a repeat is found: the run's own key, else the first saved cohort that matches."""
|
|
466
|
+
own, *saved = (cohort / str(repeat) for cohort in cohorts)
|
|
467
|
+
if own.exists():
|
|
468
|
+
return own
|
|
469
|
+
return next((entry for entry in saved if _saved_match(entry, contract, task)), None)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def preview(
|
|
473
|
+
*, path: Path, tasks: tuple[TaskSpec, ...], repeats: int, identity: dict[str, object]
|
|
474
|
+
) -> list[dict[str, object]]:
|
|
475
|
+
"""Hit or miss for every vanilla attempt from saved entries alone; `reserve` validates."""
|
|
476
|
+
entries: list[dict[str, Any]] = [
|
|
477
|
+
{"task_id": task.task_id, "arm": "baseline", "attempt": repeat}
|
|
478
|
+
for task in tasks
|
|
479
|
+
for repeat in range(1, repeats + 1)
|
|
480
|
+
]
|
|
481
|
+
contexts = cohort_contexts(tasks=tasks, entries=entries, identity=identity)
|
|
482
|
+
contracts = {task.task_id: _cohort_contract(contexts[task.task_id]) for task in tasks}
|
|
483
|
+
cohorts = {
|
|
484
|
+
task.task_id: _cohorts(
|
|
485
|
+
path, _cohort_key(contexts[task.task_id], task), contracts[task.task_id], task
|
|
486
|
+
)
|
|
487
|
+
for task in tasks
|
|
488
|
+
}
|
|
489
|
+
by_id = {task.task_id: task for task in tasks}
|
|
490
|
+
rows = []
|
|
491
|
+
for entry in entries:
|
|
492
|
+
task_id = entry["task_id"]
|
|
493
|
+
found = _resolve(cohorts[task_id], entry["attempt"], contracts[task_id], by_id[task_id])
|
|
494
|
+
rows.append(
|
|
495
|
+
{
|
|
496
|
+
"task_id": entry["task_id"],
|
|
497
|
+
"repeat": entry["attempt"],
|
|
498
|
+
"status": "hit" if found else "miss",
|
|
499
|
+
"cache_key": (found.parent if found else cohorts[entry["task_id"]][0]).name,
|
|
500
|
+
}
|
|
501
|
+
)
|
|
502
|
+
return rows
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
class BaselineCache:
|
|
506
|
+
"""Reserve every cohort key before calls; publish each finished vanilla attempt as it ends.
|
|
507
|
+
|
|
508
|
+
A cohort key names one task's vanilla context. Each repeat is its own entry,
|
|
509
|
+
`<key>/<repeat>`, so a run that stops midway still caches the repeats it finished.
|
|
510
|
+
"""
|
|
511
|
+
|
|
512
|
+
def __init__(
|
|
513
|
+
self,
|
|
514
|
+
*,
|
|
515
|
+
path: Path,
|
|
516
|
+
root: Path,
|
|
517
|
+
tasks: tuple[TaskSpec, ...],
|
|
518
|
+
entries: list[dict[str, Any]],
|
|
519
|
+
identity: dict[str, object],
|
|
520
|
+
) -> None:
|
|
521
|
+
self.path = path.absolute()
|
|
522
|
+
self.root = root
|
|
523
|
+
self.tasks = {task.task_id: task for task in tasks}
|
|
524
|
+
self.entries = [entry for entry in entries if entry["arm"] == "baseline"]
|
|
525
|
+
self.contexts = cohort_contexts(tasks=tasks, entries=self.entries, identity=identity)
|
|
526
|
+
self.contracts = {
|
|
527
|
+
task_id: _cohort_contract(context) for task_id, context in self.contexts.items()
|
|
528
|
+
}
|
|
529
|
+
self.keys = {
|
|
530
|
+
task_id: _cohort_key(context, self.tasks[task_id])
|
|
531
|
+
for task_id, context in self.contexts.items()
|
|
532
|
+
}
|
|
533
|
+
self.hits: dict[tuple[str, int], dict[str, Any]] = {}
|
|
534
|
+
# Where each hit lives: this run's own key, or a saved cohort under an older key.
|
|
535
|
+
self.origins: dict[tuple[str, int], Path] = {}
|
|
536
|
+
self._resources = ExitStack()
|
|
537
|
+
|
|
538
|
+
def reserve(self) -> BaselineCache:
|
|
539
|
+
if any(path.is_symlink() for path in (self.path, *self.path.parents)):
|
|
540
|
+
raise ValueError("baseline cache must not follow symlinks")
|
|
541
|
+
(self.path / ".locks").mkdir(parents=True, exist_ok=True)
|
|
542
|
+
if (self.path / ".locks").is_symlink():
|
|
543
|
+
raise ValueError("baseline cache locks must not follow symlinks")
|
|
544
|
+
try:
|
|
545
|
+
for task_id, key in sorted(self.keys.items(), key=lambda item: item[1]):
|
|
546
|
+
self._reserve(task_id, key)
|
|
547
|
+
except BaseException:
|
|
548
|
+
self.close()
|
|
549
|
+
raise
|
|
550
|
+
return self
|
|
551
|
+
|
|
552
|
+
def _reserve(self, task_id: str, key: str) -> None:
|
|
553
|
+
"""Lock this task's cohorts and validate each repeat where it is first found.
|
|
554
|
+
|
|
555
|
+
Repeats resolve one by one, own key first, so a cohort that a 942f12c run started and
|
|
556
|
+
this build finished is read from both places.
|
|
557
|
+
"""
|
|
558
|
+
cohorts = _cohorts(self.path, key, self.contracts[task_id], self.tasks[task_id])
|
|
559
|
+
if any(path.is_symlink() for path in cohorts):
|
|
560
|
+
raise ValueError("baseline cache cohort must not follow symlinks")
|
|
561
|
+
repeats = self.contexts[task_id]["repeats"]
|
|
562
|
+
task, contract = self.tasks[task_id], self.contracts[task_id]
|
|
563
|
+
found = {repeat: _resolve(cohorts, repeat, contract, task) for repeat in repeats}
|
|
564
|
+
if any(path is not None and path.is_symlink() for path in found.values()):
|
|
565
|
+
raise ValueError("baseline cache entry must not follow symlinks")
|
|
566
|
+
# Exclusive on this run's key while it may still publish a missing repeat; shared on
|
|
567
|
+
# saved cohorts, which this build only reads.
|
|
568
|
+
complete = all(found.values())
|
|
569
|
+
own = self._lock(key, fcntl.LOCK_SH if complete else fcntl.LOCK_EX)
|
|
570
|
+
readable = [cohorts[0]]
|
|
571
|
+
for cohort in cohorts[1:]:
|
|
572
|
+
try:
|
|
573
|
+
self._lock(cohort.name, fcntl.LOCK_SH)
|
|
574
|
+
except OSError:
|
|
575
|
+
continue # A saved cohort whose lock cannot be opened is not read.
|
|
576
|
+
readable.append(cohort)
|
|
577
|
+
for repeat in repeats:
|
|
578
|
+
for cohort in readable:
|
|
579
|
+
entry = cohort / str(repeat)
|
|
580
|
+
if not entry.exists():
|
|
581
|
+
continue
|
|
582
|
+
if cohort == cohorts[0]:
|
|
583
|
+
manifest = self._validate(task_id, entry)
|
|
584
|
+
else:
|
|
585
|
+
try:
|
|
586
|
+
manifest = self._validate(task_id, entry)
|
|
587
|
+
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
|
588
|
+
# A saved entry that does not validate, malformed evidence included,
|
|
589
|
+
# is not this repeat.
|
|
590
|
+
continue
|
|
591
|
+
self.hits[(task_id, repeat)] = manifest
|
|
592
|
+
self.origins[(task_id, repeat)] = entry
|
|
593
|
+
break
|
|
594
|
+
if complete and not all((task_id, repeat) in self.hits for repeat in repeats):
|
|
595
|
+
self._flock(own, key, fcntl.LOCK_EX)
|
|
596
|
+
|
|
597
|
+
def _lock(self, key: str, mode: int) -> object:
|
|
598
|
+
lock_path = self.path / ".locks" / f"{key}.lock"
|
|
599
|
+
if lock_path.is_symlink():
|
|
600
|
+
raise ValueError("baseline cache lock must not follow symlinks")
|
|
601
|
+
lock = self._resources.enter_context(lock_path.open("a"))
|
|
602
|
+
self._flock(lock, key, mode)
|
|
603
|
+
return lock
|
|
604
|
+
|
|
605
|
+
@staticmethod
|
|
606
|
+
def _flock(lock: Any, key: str, mode: int) -> None:
|
|
607
|
+
try:
|
|
608
|
+
fcntl.flock(lock, mode | fcntl.LOCK_NB)
|
|
609
|
+
except BlockingIOError as exc:
|
|
610
|
+
raise ValueError(
|
|
611
|
+
f"baseline cache cohort is in use: {key}; wait for its owning run to finish"
|
|
612
|
+
) from exc
|
|
613
|
+
|
|
614
|
+
def close(self) -> None:
|
|
615
|
+
self._resources.close()
|
|
616
|
+
|
|
617
|
+
def _validate(self, task_id: str, cached: Path) -> dict[str, Any]:
|
|
618
|
+
"""Check one `<key>/<repeat>` entry: a single complete, eligible vanilla attempt."""
|
|
619
|
+
if cached.is_symlink() or not cached.is_dir():
|
|
620
|
+
raise ValueError("baseline cache entry must be a directory")
|
|
621
|
+
manifest: dict[str, Any] = json.loads((cached / "manifest.json").read_text())
|
|
622
|
+
if (
|
|
623
|
+
not _same_cohort(manifest["context"], self.contracts[task_id], self.tasks[task_id])
|
|
624
|
+
or manifest["inventory"] != _inventory(cached)
|
|
625
|
+
or manifest["origin_status"] != "complete"
|
|
626
|
+
):
|
|
627
|
+
raise ValueError(f"baseline cache cohort changed: {cached.name}")
|
|
628
|
+
records = manifest["records"]
|
|
629
|
+
for record in records:
|
|
630
|
+
AttemptInput.model_validate(record)
|
|
631
|
+
for identifier in [record["id"], *(stage["id"] for stage in record["stages"])]:
|
|
632
|
+
if Path(identifier).name != identifier or identifier in {".", ".."}:
|
|
633
|
+
raise ValueError(
|
|
634
|
+
"baseline cache artifact identity must be a single path component"
|
|
635
|
+
)
|
|
636
|
+
if [record["attempt"] for record in records] != [int(cached.name)] or any(
|
|
637
|
+
record["task_id"] != task_id
|
|
638
|
+
or record["arm"] != "baseline"
|
|
639
|
+
# The evidence is checked against the identity it was cached under.
|
|
640
|
+
or not _eligible(
|
|
641
|
+
attempt=cached / "attempts" / record["id"],
|
|
642
|
+
record=record,
|
|
643
|
+
coding=manifest["context"]["coding"],
|
|
644
|
+
task=self.tasks[task_id],
|
|
645
|
+
)
|
|
646
|
+
for record in records
|
|
647
|
+
):
|
|
648
|
+
raise ValueError(
|
|
649
|
+
"baseline cache cohort lacks complete isolated native baseline evidence"
|
|
650
|
+
)
|
|
651
|
+
return manifest
|
|
652
|
+
|
|
653
|
+
def decisions(self) -> dict[str, dict[str, Any]]:
|
|
654
|
+
decisions = {}
|
|
655
|
+
for entry in self.entries:
|
|
656
|
+
task_id = entry["task_id"]
|
|
657
|
+
hit = self.hits.get((task_id, entry["attempt"]))
|
|
658
|
+
origin = self.origins.get((task_id, entry["attempt"]))
|
|
659
|
+
key = origin.parent.name if origin else self.keys[task_id]
|
|
660
|
+
# Relative to the cache root, so moving the cache or this run keeps it resolvable.
|
|
661
|
+
# Historical decisions held an absolute `origin_run` path instead.
|
|
662
|
+
decisions[entry["id"]] = {
|
|
663
|
+
"status": "hit" if hit else "miss",
|
|
664
|
+
"cache_key": key,
|
|
665
|
+
"origin_cohort": f"{self.path.name}/{key}/{entry['attempt']}" if hit else None,
|
|
666
|
+
"origin_attempt": hit["records"][0]["id"] if hit else None,
|
|
667
|
+
}
|
|
668
|
+
return decisions
|
|
669
|
+
|
|
670
|
+
def import_attempt(self, entry: dict[str, Any]) -> dict[str, Any] | None:
|
|
671
|
+
if entry["arm"] != "baseline":
|
|
672
|
+
return None
|
|
673
|
+
manifest = self.hits.get((entry["task_id"], entry["attempt"]))
|
|
674
|
+
if manifest is None:
|
|
675
|
+
return None
|
|
676
|
+
record: dict[str, Any] = copy.deepcopy(manifest["records"][0])
|
|
677
|
+
old_id = record["id"]
|
|
678
|
+
cached = self.origins[(entry["task_id"], entry["attempt"])]
|
|
679
|
+
attempt = self.root / "attempts" / entry["id"]
|
|
680
|
+
attempt.parent.mkdir(parents=True, exist_ok=True)
|
|
681
|
+
shutil.copytree(cached / "attempts" / old_id, attempt, symlinks=True)
|
|
682
|
+
prefix = f"attempts/{old_id}/"
|
|
683
|
+
if _inventory(attempt) != {
|
|
684
|
+
name.removeprefix(prefix): metadata
|
|
685
|
+
for name, metadata in manifest["inventory"].items()
|
|
686
|
+
if name.startswith(prefix)
|
|
687
|
+
}:
|
|
688
|
+
raise ValueError("baseline cache artifacts changed while importing")
|
|
689
|
+
origin = self.root / "baseline-evidence" / entry["id"]
|
|
690
|
+
origin.mkdir(parents=True)
|
|
691
|
+
shutil.copyfile(cached / "native-starts" / f"{old_id}.json", origin / "native-starts.json")
|
|
692
|
+
# Retain observer evidence, but only the current verifier/collectors may grade this run.
|
|
693
|
+
for path in [
|
|
694
|
+
attempt / "before-evidence",
|
|
695
|
+
*[
|
|
696
|
+
attempt / stage["id"] / name
|
|
697
|
+
for stage in record["stages"]
|
|
698
|
+
for name in ("correctness.json", "verifier-result.json", "evidence")
|
|
699
|
+
],
|
|
700
|
+
]:
|
|
701
|
+
if path.exists():
|
|
702
|
+
target = origin / "observers" / path.relative_to(attempt)
|
|
703
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
704
|
+
path.rename(target)
|
|
705
|
+
native_artifacts = [
|
|
706
|
+
path
|
|
707
|
+
for stage in record["stages"]
|
|
708
|
+
for path in (attempt / stage["id"] / "artifacts").rglob("*")
|
|
709
|
+
if path.is_file()
|
|
710
|
+
]
|
|
711
|
+
raw_archives = list(attempt.glob("*/unsupported-submission.tar"))
|
|
712
|
+
temporary = origin / "origin.tmp"
|
|
713
|
+
historical = [path for path in origin.rglob("*") if path.is_file()]
|
|
714
|
+
temporary.write_text(
|
|
715
|
+
json.dumps(
|
|
716
|
+
{
|
|
717
|
+
**self.decisions()[entry["id"]],
|
|
718
|
+
"context": manifest["context"],
|
|
719
|
+
"original_record": record,
|
|
720
|
+
"original_task_digest": manifest["task_digest"],
|
|
721
|
+
"historical_evidence": [
|
|
722
|
+
{
|
|
723
|
+
"path": path.relative_to(self.root).as_posix(),
|
|
724
|
+
"sha256": "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest(),
|
|
725
|
+
}
|
|
726
|
+
for path in sorted([*historical, *native_artifacts, *raw_archives])
|
|
727
|
+
],
|
|
728
|
+
},
|
|
729
|
+
indent=2,
|
|
730
|
+
sort_keys=True,
|
|
731
|
+
)
|
|
732
|
+
+ "\n"
|
|
733
|
+
)
|
|
734
|
+
temporary.replace(origin / "origin.json")
|
|
735
|
+
record.update(id=entry["id"], before=None, before_evidence=None)
|
|
736
|
+
for stage in record["stages"]:
|
|
737
|
+
if stage["snapshot"]:
|
|
738
|
+
stage["snapshot"]["path"] = f"attempts/{entry['id']}/{stage['id']}/snapshot"
|
|
739
|
+
stage.update(correctness=None, evidence=None)
|
|
740
|
+
return record
|
|
741
|
+
|
|
742
|
+
def publish(self, record: dict[str, Any]) -> None:
|
|
743
|
+
"""Cache one finished vanilla attempt now, unless it is a hit or not eligible."""
|
|
744
|
+
task_id = record["task_id"]
|
|
745
|
+
if record["arm"] != "baseline" or (task_id, record["attempt"]) in self.hits:
|
|
746
|
+
return
|
|
747
|
+
key = self.keys[task_id]
|
|
748
|
+
attempt = self.root / "attempts" / record["id"]
|
|
749
|
+
if not _eligible(
|
|
750
|
+
attempt=attempt,
|
|
751
|
+
record=record,
|
|
752
|
+
coding=self.contexts[task_id]["coding"],
|
|
753
|
+
task=self.tasks[task_id],
|
|
754
|
+
):
|
|
755
|
+
return
|
|
756
|
+
# A session that never reached the model is no baseline. Runs from before the coding
|
|
757
|
+
# pass stopped on such a session recorded it as complete.
|
|
758
|
+
if any(
|
|
759
|
+
failed_before_first_turn(
|
|
760
|
+
json.loads((attempt / stage["id"] / "execution.json").read_text())
|
|
761
|
+
)
|
|
762
|
+
for stage in record["stages"]
|
|
763
|
+
):
|
|
764
|
+
return
|
|
765
|
+
cohort = self.path / key
|
|
766
|
+
cohort.mkdir(exist_ok=True)
|
|
767
|
+
with tempfile.TemporaryDirectory(prefix=f".{record['attempt']}-", dir=cohort) as temporary:
|
|
768
|
+
staged = Path(temporary) / str(record["attempt"])
|
|
769
|
+
(staged / "attempts").mkdir(parents=True)
|
|
770
|
+
(staged / "native-starts").mkdir()
|
|
771
|
+
_copy_attempt(self.root / "attempts" / record["id"], staged / "attempts" / record["id"])
|
|
772
|
+
receipts = {
|
|
773
|
+
stage["id"]: json.loads(
|
|
774
|
+
(self.root / "invocations" / record["id"] / f"{stage['id']}.json").read_text()
|
|
775
|
+
)
|
|
776
|
+
for stage in record["stages"]
|
|
777
|
+
}
|
|
778
|
+
(staged / "native-starts" / f"{record['id']}.json").write_text(
|
|
779
|
+
json.dumps(receipts, indent=2, sort_keys=True) + "\n"
|
|
780
|
+
)
|
|
781
|
+
manifest = {
|
|
782
|
+
"context": self.contexts[task_id],
|
|
783
|
+
"records": [record],
|
|
784
|
+
"origin_status": "complete",
|
|
785
|
+
"task_digest": self.tasks[task_id].digest,
|
|
786
|
+
"inventory": _inventory(staged),
|
|
787
|
+
}
|
|
788
|
+
(staged / "manifest.json").write_text(
|
|
789
|
+
json.dumps(manifest, indent=2, sort_keys=True) + "\n"
|
|
790
|
+
)
|
|
791
|
+
self._validate(task_id, staged)
|
|
792
|
+
staged.rename(cohort / staged.name)
|