benchkit-cli 0.1.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.
- benchkit/__init__.py +1 -0
- benchkit/_pi_proxy.py +219 -0
- benchkit/answer_key_guard.ts +49 -0
- benchkit/artifacts.py +87 -0
- benchkit/benchmarks/__init__.py +105 -0
- benchkit/benchmarks/aider_polyglot.py +405 -0
- benchkit/benchmarks/arc.py +52 -0
- benchkit/benchmarks/banking77.py +149 -0
- benchkit/benchmarks/base.py +19 -0
- benchkit/benchmarks/boolq.py +49 -0
- benchkit/benchmarks/chartqa.py +133 -0
- benchkit/benchmarks/code_verifier.py +77 -0
- benchkit/benchmarks/downloads.py +166 -0
- benchkit/benchmarks/evalplus.py +288 -0
- benchkit/benchmarks/git_surgery.py +485 -0
- benchkit/benchmarks/gpqa.py +56 -0
- benchkit/benchmarks/gsm8k.py +97 -0
- benchkit/benchmarks/hellaswag.py +49 -0
- benchkit/benchmarks/humaneval.py +103 -0
- benchkit/benchmarks/ifeval.py +56 -0
- benchkit/benchmarks/ifeval_instructions.py +477 -0
- benchkit/benchmarks/mbpp.py +60 -0
- benchkit/benchmarks/mc_arena.py +479 -0
- benchkit/benchmarks/mcq.py +94 -0
- benchkit/benchmarks/medfailbench.py +537 -0
- benchkit/benchmarks/mmlu.py +54 -0
- benchkit/benchmarks/mmlu_pro.py +62 -0
- benchkit/benchmarks/mmvp.py +115 -0
- benchkit/benchmarks/ocrbench.py +96 -0
- benchkit/benchmarks/openbookqa.py +52 -0
- benchkit/benchmarks/patcheval.py +929 -0
- benchkit/benchmarks/piqa.py +52 -0
- benchkit/benchmarks/ruler.py +172 -0
- benchkit/benchmarks/ruler_generator.py +535 -0
- benchkit/benchmarks/sanity.py +174 -0
- benchkit/benchmarks/treejs_arena.py +268 -0
- benchkit/benchmarks/truthfulqa.py +52 -0
- benchkit/benchmarks/utils.py +25 -0
- benchkit/benchmarks/vision.py +230 -0
- benchkit/benchmarks/vstar.py +108 -0
- benchkit/benchmarks/winogrande.py +52 -0
- benchkit/benchmarks/xstest.py +116 -0
- benchkit/browser.py +429 -0
- benchkit/catalog.py +121 -0
- benchkit/cli.py +1000 -0
- benchkit/client.py +1413 -0
- benchkit/controls.py +87 -0
- benchkit/datasets/MMLU_LICENSE.txt +21 -0
- benchkit/datasets/README.md +142 -0
- benchkit/datasets/arc_challenge.jsonl +1172 -0
- benchkit/datasets/banking77.jsonl +3080 -0
- benchkit/datasets/banking77_intents.json +79 -0
- benchkit/datasets/boolq.jsonl +3270 -0
- benchkit/datasets/code_verifier.jsonl +500 -0
- benchkit/datasets/gpqa_diamond.jsonl +198 -0
- benchkit/datasets/gsm8k.jsonl +1319 -0
- benchkit/datasets/hellaswag.jsonl +1000 -0
- benchkit/datasets/humaneval.jsonl +164 -0
- benchkit/datasets/ifeval.jsonl +541 -0
- benchkit/datasets/mbpp.jsonl +500 -0
- benchkit/datasets/mc_blocks_1_20_1.jsonl +1003 -0
- benchkit/datasets/medfailbench_hard30.jsonl +30 -0
- benchkit/datasets/medfailbench_hard30.md +13 -0
- benchkit/datasets/openbookqa.jsonl +500 -0
- benchkit/datasets/piqa.jsonl +1838 -0
- benchkit/datasets/sanity_code.jsonl +6 -0
- benchkit/datasets/truthfulqa.jsonl +817 -0
- benchkit/datasets/winogrande.jsonl +1267 -0
- benchkit/datasets/xstest.csv +451 -0
- benchkit/decision.py +194 -0
- benchkit/demo.py +451 -0
- benchkit/dockerfiles.py +247 -0
- benchkit/engine.py +1270 -0
- benchkit/evaluation.py +195 -0
- benchkit/events.py +141 -0
- benchkit/executor.py +96 -0
- benchkit/git_surgery/backport-release-stack/setup.sh +328 -0
- benchkit/git_surgery/backport-release-stack/verify.sh +119 -0
- benchkit/git_surgery/bisect-the-regression/setup.sh +49 -0
- benchkit/git_surgery/bisect-the-regression/verify.sh +34 -0
- benchkit/git_surgery/rebase-conflict-chain/setup.sh +65 -0
- benchkit/git_surgery/rebase-conflict-chain/verify.sh +43 -0
- benchkit/git_surgery/recover-complex-stash/setup.sh +152 -0
- benchkit/git_surgery/recover-complex-stash/verify.sh +95 -0
- benchkit/git_surgery/recover-lost-work/setup.sh +40 -0
- benchkit/git_surgery/recover-lost-work/verify.sh +36 -0
- benchkit/git_surgery/repair-force-pushed-remote/setup.sh +171 -0
- benchkit/git_surgery/repair-force-pushed-remote/verify.sh +83 -0
- benchkit/git_surgery/revert-merge-with-followups/setup.sh +198 -0
- benchkit/git_surgery/revert-merge-with-followups/verify.sh +98 -0
- benchkit/git_surgery/secret-in-history/README.md +27 -0
- benchkit/git_surgery/secret-in-history/setup.sh +107 -0
- benchkit/git_surgery/secret-in-history/verify.sh +106 -0
- benchkit/git_surgery/split-the-mega-commit/setup.sh +50 -0
- benchkit/git_surgery/split-the-mega-commit/verify.sh +48 -0
- benchkit/git_surgery/untangle-nested-submodules/setup.sh +193 -0
- benchkit/git_surgery/untangle-nested-submodules/verify.sh +90 -0
- benchkit/history.py +474 -0
- benchkit/jobs.py +332 -0
- benchkit/leakage.py +51 -0
- benchkit/looping.py +873 -0
- benchkit/mc_render.py +274 -0
- benchkit/mc_viewer/dist/atlas.png +0 -0
- benchkit/mc_viewer/dist/blockStates.json.gz +0 -0
- benchkit/mc_viewer/dist/version.json +4 -0
- benchkit/mc_viewer/dist/viewer.html +23 -0
- benchkit/mc_viewer/dist/viewer.js +2 -0
- benchkit/mc_viewer/dist/viewer.js.LICENSE.txt +25 -0
- benchkit/mc_viewer/dist/worker.js +2 -0
- benchkit/mc_viewer/dist/worker.js.LICENSE.txt +19 -0
- benchkit/metrics.py +100 -0
- benchkit/ollama.py +60 -0
- benchkit/perf.py +353 -0
- benchkit/perf_report.py +207 -0
- benchkit/perturbations.py +208 -0
- benchkit/pi_agent.py +476 -0
- benchkit/pi_package/package-lock.json +1827 -0
- benchkit/pi_package/package.json +8 -0
- benchkit/report.py +142 -0
- benchkit/report_markdown.py +645 -0
- benchkit/results.py +688 -0
- benchkit/retry.py +163 -0
- benchkit/runner.py +769 -0
- benchkit/sandbox.py +1167 -0
- benchkit/template_check.py +291 -0
- benchkit/templates/arena.html +504 -0
- benchkit/templates/history.html +58 -0
- benchkit/templates/perf.html +509 -0
- benchkit/templates/report.html +769 -0
- benchkit/tui/__init__.py +5 -0
- benchkit/tui/app.py +87 -0
- benchkit/tui/app.tcss +512 -0
- benchkit/tui/formatting.py +84 -0
- benchkit/tui/screens/__init__.py +1 -0
- benchkit/tui/screens/connect.py +191 -0
- benchkit/tui/screens/detail.py +288 -0
- benchkit/tui/screens/help.py +84 -0
- benchkit/tui/screens/modals.py +544 -0
- benchkit/tui/screens/results.py +361 -0
- benchkit/tui/screens/run.py +988 -0
- benchkit/tui/screens/setup.py +733 -0
- benchkit/tui/theme.py +135 -0
- benchkit/tui/widgets.py +120 -0
- benchkit_cli-0.1.0.dist-info/METADATA +294 -0
- benchkit_cli-0.1.0.dist-info/RECORD +149 -0
- benchkit_cli-0.1.0.dist-info/WHEEL +5 -0
- benchkit_cli-0.1.0.dist-info/entry_points.txt +3 -0
- benchkit_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- benchkit_cli-0.1.0.dist-info/top_level.txt +1 -0
benchkit/sandbox.py
ADDED
|
@@ -0,0 +1,1167 @@
|
|
|
1
|
+
"""Persistent Docker environments for unmodified coding-agent harnesses.
|
|
2
|
+
|
|
3
|
+
Builds the images described in :mod:`benchkit.dockerfiles`, runs Pi tasks and
|
|
4
|
+
mc-arena scripts in them, and removes everything a run created.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import tempfile
|
|
17
|
+
import threading
|
|
18
|
+
import uuid
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path, PurePosixPath
|
|
21
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
22
|
+
|
|
23
|
+
from benchkit.client import InferenceClient, _openai_base
|
|
24
|
+
from benchkit.dockerfiles import (
|
|
25
|
+
AIDER_PI_DOCKERFILE,
|
|
26
|
+
GIT_SURGERY_PI_DOCKERFILE,
|
|
27
|
+
PI_DOCKERFILE,
|
|
28
|
+
PatchEvalRuntimeRecipe,
|
|
29
|
+
mc_arena_dockerfile,
|
|
30
|
+
patcheval_dockerfile,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# The Aider suite reads its pinned commit from here.
|
|
34
|
+
from benchkit.dockerfiles import AIDER_POLYGLOT_COMMIT as AIDER_POLYGLOT_COMMIT
|
|
35
|
+
|
|
36
|
+
_PI_IMAGE_SESSION = uuid.uuid4().hex[:12]
|
|
37
|
+
PI_IMAGE = f"benchkit-pi:{_PI_IMAGE_SESSION}"
|
|
38
|
+
AIDER_PI_IMAGE = f"benchkit-pi-aider-polyglot:{_PI_IMAGE_SESSION}"
|
|
39
|
+
GIT_SURGERY_PI_IMAGE = f"benchkit-pi-git-surgery:{_PI_IMAGE_SESSION}"
|
|
40
|
+
PI_PACKAGE = "@earendil-works/pi-coding-agent@0.84.2"
|
|
41
|
+
PI_VERSION = "0.84.2"
|
|
42
|
+
_PROXY_SOURCE = Path(__file__).with_name("_pi_proxy.py")
|
|
43
|
+
_ANSWER_KEY_GUARD_SOURCE = Path(__file__).with_name("answer_key_guard.ts")
|
|
44
|
+
_PI_PACKAGE_ROOT = Path(__file__).with_name("pi_package")
|
|
45
|
+
_PATCHEVAL_IMAGE_SESSION = uuid.uuid4().hex[:12]
|
|
46
|
+
_MC_ARENA_IMAGE_SESSION = uuid.uuid4().hex[:12]
|
|
47
|
+
_BUILDKIT_DRIVER_IMAGE = "moby/buildkit:buildx-stable-1"
|
|
48
|
+
|
|
49
|
+
# One label scopes every image, container, network, volume, and build-cache
|
|
50
|
+
# entry that this process creates, so cleanup can remove exactly its own
|
|
51
|
+
# resources instead of pruning Docker globally.
|
|
52
|
+
RUN_ID = uuid.uuid4().hex[:16]
|
|
53
|
+
RUN_LABEL = f"benchkit.run={RUN_ID}"
|
|
54
|
+
MANAGED_LABEL = "benchkit.managed=true"
|
|
55
|
+
_BUILDER = f"benchkit-build-{RUN_ID}"
|
|
56
|
+
_BUILDKIT_CONTAINER = f"buildx_buildkit_{_BUILDER}0"
|
|
57
|
+
_BUILDKIT_VOLUME = f"buildx_buildkit_{_BUILDER}0_state"
|
|
58
|
+
_UV_CACHE_ID = f"benchkit-uv-{RUN_ID}"
|
|
59
|
+
_BUILD_LOCK = threading.Lock()
|
|
60
|
+
_BUILD_STATE: dict[str, object] = {
|
|
61
|
+
"builder": False,
|
|
62
|
+
"driver_was_present": True,
|
|
63
|
+
"pulled": set(),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def resource_labels(owner_id: str | None = None) -> list[str]:
|
|
68
|
+
"""Return the label flags every managed Docker resource must carry."""
|
|
69
|
+
labels = ["--label", MANAGED_LABEL, "--label", RUN_LABEL]
|
|
70
|
+
if owner_id is not None:
|
|
71
|
+
labels.extend(["--label", f"benchkit.owner={owner_id}"])
|
|
72
|
+
return labels
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SandboxError(RuntimeError):
|
|
76
|
+
"""The Docker-backed agent environment could not be prepared or used."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _docker_binary() -> str:
|
|
80
|
+
requested = os.environ.get("BENCHKIT_DOCKER", "docker")
|
|
81
|
+
resolved = shutil.which(requested)
|
|
82
|
+
if resolved is None:
|
|
83
|
+
raise SandboxError(
|
|
84
|
+
"Pi harness requires Docker; install Docker and ensure `docker` is on PATH"
|
|
85
|
+
)
|
|
86
|
+
return resolved
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _tail(text: str, limit: int = 4000) -> str:
|
|
90
|
+
text = text.strip()
|
|
91
|
+
return text[-limit:] if len(text) > limit else text
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _run(
|
|
95
|
+
args: list[str],
|
|
96
|
+
*,
|
|
97
|
+
input_text: str | None = None,
|
|
98
|
+
timeout: float | None = None,
|
|
99
|
+
check: bool = True,
|
|
100
|
+
) -> subprocess.CompletedProcess[str]:
|
|
101
|
+
try:
|
|
102
|
+
completed = subprocess.run(
|
|
103
|
+
args,
|
|
104
|
+
input=input_text,
|
|
105
|
+
text=True,
|
|
106
|
+
errors="replace",
|
|
107
|
+
capture_output=True,
|
|
108
|
+
timeout=timeout,
|
|
109
|
+
)
|
|
110
|
+
except subprocess.TimeoutExpired as exc:
|
|
111
|
+
raise SandboxError(f"Docker command timed out: {args[1]}") from exc
|
|
112
|
+
except OSError as exc:
|
|
113
|
+
raise SandboxError(f"Could not run Docker: {exc}") from exc
|
|
114
|
+
if check and completed.returncode:
|
|
115
|
+
detail = _tail(completed.stderr or completed.stdout) or "unknown Docker error"
|
|
116
|
+
raise SandboxError(f"Docker {args[1]} failed: {detail}")
|
|
117
|
+
return completed
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _docker_upstream(url: str) -> str:
|
|
121
|
+
"""Make a host-local inference URL reachable from the proxy container."""
|
|
122
|
+
parsed = urlsplit(_openai_base(url))
|
|
123
|
+
host = parsed.hostname or ""
|
|
124
|
+
if host not in {"localhost", "127.0.0.1", "::1"}:
|
|
125
|
+
return urlunsplit(parsed)
|
|
126
|
+
replacement = "host.docker.internal"
|
|
127
|
+
if parsed.port:
|
|
128
|
+
replacement += f":{parsed.port}"
|
|
129
|
+
return urlunsplit((parsed.scheme, replacement, parsed.path, parsed.query, ""))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def cleanup_owned_resources(docker: str, owner_id: str) -> None:
|
|
133
|
+
"""Remove every container and network belonging to one Pi runner."""
|
|
134
|
+
label = f"benchkit.owner={owner_id}"
|
|
135
|
+
errors: list[str] = []
|
|
136
|
+
|
|
137
|
+
def listed(command: list[str], kind: str) -> list[str]:
|
|
138
|
+
try:
|
|
139
|
+
result = _run(command, timeout=30, check=False)
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
errors.append(f"could not inspect owned {kind}: {exc}")
|
|
142
|
+
return []
|
|
143
|
+
if result.returncode:
|
|
144
|
+
detail = _tail(result.stderr or result.stdout) or "unknown Docker error"
|
|
145
|
+
errors.append(f"could not inspect owned {kind}: {detail}")
|
|
146
|
+
return []
|
|
147
|
+
return result.stdout.split()
|
|
148
|
+
|
|
149
|
+
container_query = [
|
|
150
|
+
docker,
|
|
151
|
+
"ps",
|
|
152
|
+
"--all",
|
|
153
|
+
"--quiet",
|
|
154
|
+
"--filter",
|
|
155
|
+
f"label={label}",
|
|
156
|
+
]
|
|
157
|
+
network_query = [
|
|
158
|
+
docker,
|
|
159
|
+
"network",
|
|
160
|
+
"ls",
|
|
161
|
+
"--quiet",
|
|
162
|
+
"--filter",
|
|
163
|
+
f"label={label}",
|
|
164
|
+
]
|
|
165
|
+
containers = listed(container_query, "containers")
|
|
166
|
+
if containers:
|
|
167
|
+
with contextlib.suppress(Exception):
|
|
168
|
+
_run([docker, "rm", "--force", *containers], timeout=30, check=False)
|
|
169
|
+
networks = listed(network_query, "networks")
|
|
170
|
+
if networks:
|
|
171
|
+
with contextlib.suppress(Exception):
|
|
172
|
+
_run([docker, "network", "rm", *networks], timeout=30, check=False)
|
|
173
|
+
|
|
174
|
+
remaining_containers = listed(container_query, "containers after cleanup")
|
|
175
|
+
remaining_networks = listed(network_query, "networks after cleanup")
|
|
176
|
+
if remaining_containers:
|
|
177
|
+
errors.append("owned containers remain: " + ", ".join(remaining_containers))
|
|
178
|
+
if remaining_networks:
|
|
179
|
+
errors.append("owned networks remain: " + ", ".join(remaining_networks))
|
|
180
|
+
if errors:
|
|
181
|
+
raise SandboxError("Pi Docker cleanup failed: " + "; ".join(errors))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _verify_absent(
|
|
185
|
+
command: list[str], description: str, errors: list[str], *, timeout: int = 30
|
|
186
|
+
) -> None:
|
|
187
|
+
"""Record an error unless an exact Docker resource is confirmed absent."""
|
|
188
|
+
try:
|
|
189
|
+
result = _run(command, timeout=timeout, check=False)
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
errors.append(f"could not verify {description}: {exc}")
|
|
192
|
+
return
|
|
193
|
+
if result.returncode == 0:
|
|
194
|
+
errors.append(f"{description} remains")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass
|
|
198
|
+
class LatestPiImage:
|
|
199
|
+
"""Build a reproducibly pinned Pi image; the engine builds one per task."""
|
|
200
|
+
|
|
201
|
+
docker: str = field(default_factory=_docker_binary)
|
|
202
|
+
image: str = PI_IMAGE
|
|
203
|
+
dockerfile: str = PI_DOCKERFILE
|
|
204
|
+
transient: bool = True
|
|
205
|
+
pids_limit: int = 256
|
|
206
|
+
build_assets: Path | None = None
|
|
207
|
+
build_files: tuple[tuple[Path, str], ...] = ()
|
|
208
|
+
always_cleanup_image: bool = True
|
|
209
|
+
resource_scope: str = "pi"
|
|
210
|
+
answer_key_guard: bool = False
|
|
211
|
+
version: str = ""
|
|
212
|
+
_ready: bool = field(default=False, init=False, repr=False)
|
|
213
|
+
_lock: threading.Lock = field(
|
|
214
|
+
default_factory=threading.Lock, init=False, repr=False
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def prepare(self) -> str:
|
|
218
|
+
if self._ready:
|
|
219
|
+
return self.version
|
|
220
|
+
with self._lock:
|
|
221
|
+
if self._ready:
|
|
222
|
+
return self.version
|
|
223
|
+
_run(
|
|
224
|
+
[self.docker, "version", "--format", "{{.Server.Version}}"],
|
|
225
|
+
timeout=10,
|
|
226
|
+
)
|
|
227
|
+
with tempfile.TemporaryDirectory(prefix="benchkit-pi-build-") as directory:
|
|
228
|
+
context = Path(directory)
|
|
229
|
+
(context / "Dockerfile").write_text(self.dockerfile, encoding="utf-8")
|
|
230
|
+
(context / "inference_proxy.py").write_text(
|
|
231
|
+
_PROXY_SOURCE.read_text(encoding="utf-8"),
|
|
232
|
+
encoding="utf-8",
|
|
233
|
+
)
|
|
234
|
+
(context / "answer_key_guard.ts").write_text(
|
|
235
|
+
_ANSWER_KEY_GUARD_SOURCE.read_text(encoding="utf-8"),
|
|
236
|
+
encoding="utf-8",
|
|
237
|
+
)
|
|
238
|
+
shutil.copytree(_PI_PACKAGE_ROOT, context / "pi-package")
|
|
239
|
+
if self.build_assets is not None:
|
|
240
|
+
shutil.copytree(self.build_assets, context / "git-surgery")
|
|
241
|
+
allowed = {
|
|
242
|
+
"Dockerfile",
|
|
243
|
+
"inference_proxy.py",
|
|
244
|
+
"answer_key_guard.ts",
|
|
245
|
+
"pi-package",
|
|
246
|
+
"git-surgery",
|
|
247
|
+
}
|
|
248
|
+
for source, destination in self.build_files:
|
|
249
|
+
target = PurePosixPath(destination)
|
|
250
|
+
if (
|
|
251
|
+
target.is_absolute()
|
|
252
|
+
or len(target.parts) != 1
|
|
253
|
+
or target.name in allowed
|
|
254
|
+
or source.is_symlink()
|
|
255
|
+
or not source.is_file()
|
|
256
|
+
):
|
|
257
|
+
raise SandboxError("invalid explicit Pi build-context file")
|
|
258
|
+
shutil.copyfile(source, context / target.name)
|
|
259
|
+
allowed.add(target.name)
|
|
260
|
+
dockerignore = ["*", *sorted(f"!{item}" for item in allowed)]
|
|
261
|
+
dockerignore.extend(("!pi-package/**", "!git-surgery/**"))
|
|
262
|
+
(context / ".dockerignore").write_text(
|
|
263
|
+
"\n".join(dockerignore) + "\n", encoding="utf-8"
|
|
264
|
+
)
|
|
265
|
+
try:
|
|
266
|
+
_build_with_run_builder(
|
|
267
|
+
self.docker,
|
|
268
|
+
self.image,
|
|
269
|
+
self.dockerfile,
|
|
270
|
+
context,
|
|
271
|
+
)
|
|
272
|
+
smoke = (
|
|
273
|
+
f"benchkit-{self.resource_scope}-smoke-{uuid.uuid4().hex[:16]}"
|
|
274
|
+
)
|
|
275
|
+
try:
|
|
276
|
+
result = _run(
|
|
277
|
+
[
|
|
278
|
+
self.docker,
|
|
279
|
+
"run",
|
|
280
|
+
"--name",
|
|
281
|
+
smoke,
|
|
282
|
+
"--rm",
|
|
283
|
+
*resource_labels(),
|
|
284
|
+
self.image,
|
|
285
|
+
"pi",
|
|
286
|
+
"--version",
|
|
287
|
+
],
|
|
288
|
+
timeout=30,
|
|
289
|
+
)
|
|
290
|
+
finally:
|
|
291
|
+
with contextlib.suppress(Exception):
|
|
292
|
+
_run(
|
|
293
|
+
[self.docker, "rm", "--force", smoke],
|
|
294
|
+
timeout=30,
|
|
295
|
+
check=False,
|
|
296
|
+
)
|
|
297
|
+
self.version = result.stdout.strip() or "latest"
|
|
298
|
+
if self.version != PI_VERSION:
|
|
299
|
+
raise SandboxError(
|
|
300
|
+
f"Pi image reported {self.version!r}; "
|
|
301
|
+
f"expected {PI_VERSION!r}"
|
|
302
|
+
)
|
|
303
|
+
except BaseException:
|
|
304
|
+
if self.transient:
|
|
305
|
+
with contextlib.suppress(Exception):
|
|
306
|
+
_run(
|
|
307
|
+
[
|
|
308
|
+
self.docker,
|
|
309
|
+
"image",
|
|
310
|
+
"rm",
|
|
311
|
+
"--force",
|
|
312
|
+
self.image,
|
|
313
|
+
],
|
|
314
|
+
timeout=60,
|
|
315
|
+
check=False,
|
|
316
|
+
)
|
|
317
|
+
self.version = ""
|
|
318
|
+
raise
|
|
319
|
+
self._ready = True
|
|
320
|
+
return self.version
|
|
321
|
+
|
|
322
|
+
def cleanup(self) -> None:
|
|
323
|
+
"""Remove the transient Pi image after its benchmark run."""
|
|
324
|
+
if not self.transient or (not self._ready and not self.always_cleanup_image):
|
|
325
|
+
return
|
|
326
|
+
try:
|
|
327
|
+
_run(
|
|
328
|
+
[self.docker, "image", "rm", "--force", self.image],
|
|
329
|
+
timeout=60,
|
|
330
|
+
check=not self.always_cleanup_image,
|
|
331
|
+
)
|
|
332
|
+
if self.always_cleanup_image:
|
|
333
|
+
errors: list[str] = []
|
|
334
|
+
_verify_absent(
|
|
335
|
+
[self.docker, "image", "inspect", self.image],
|
|
336
|
+
f"transient image {self.image}",
|
|
337
|
+
errors,
|
|
338
|
+
)
|
|
339
|
+
if errors:
|
|
340
|
+
raise SandboxError("Pi Docker cleanup failed: " + "; ".join(errors))
|
|
341
|
+
finally:
|
|
342
|
+
self._ready = False
|
|
343
|
+
self.version = ""
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _base_image_refs(dockerfile: str) -> tuple[str, ...]:
|
|
347
|
+
"""Return the external images a Dockerfile pulls, ignoring its own stages."""
|
|
348
|
+
stages: set[str] = set()
|
|
349
|
+
refs: list[str] = []
|
|
350
|
+
for line in dockerfile.splitlines():
|
|
351
|
+
match = re.match(r"\s*FROM\s+(\S+)(?:\s+[Aa][Ss]\s+(\S+))?\s*$", line)
|
|
352
|
+
if match is None:
|
|
353
|
+
continue
|
|
354
|
+
reference, alias = match.group(1), match.group(2)
|
|
355
|
+
if reference not in stages and reference not in refs:
|
|
356
|
+
refs.append(reference)
|
|
357
|
+
if alias:
|
|
358
|
+
stages.add(alias)
|
|
359
|
+
return tuple(refs)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _run_builder(docker: str) -> str:
|
|
363
|
+
"""Create this run's single private Buildx builder on first use.
|
|
364
|
+
|
|
365
|
+
Every build in the run shares one builder, so layers and cache mounts are
|
|
366
|
+
reused instead of rebuilt per task. The builder keeps its BuildKit state in
|
|
367
|
+
its own container and volume, which cleanup removes by exact name. That is
|
|
368
|
+
what keeps a run's build cache separable from the host's own cache without
|
|
369
|
+
ever running a global prune.
|
|
370
|
+
"""
|
|
371
|
+
with _BUILD_LOCK:
|
|
372
|
+
if not _BUILD_STATE["builder"]:
|
|
373
|
+
_BUILD_STATE["driver_was_present"] = bool(
|
|
374
|
+
_run(
|
|
375
|
+
[
|
|
376
|
+
docker,
|
|
377
|
+
"image",
|
|
378
|
+
"inspect",
|
|
379
|
+
"--format",
|
|
380
|
+
"{{.Id}}",
|
|
381
|
+
_BUILDKIT_DRIVER_IMAGE,
|
|
382
|
+
],
|
|
383
|
+
timeout=30,
|
|
384
|
+
check=False,
|
|
385
|
+
).stdout.strip()
|
|
386
|
+
)
|
|
387
|
+
_run(
|
|
388
|
+
[
|
|
389
|
+
docker,
|
|
390
|
+
"buildx",
|
|
391
|
+
"create",
|
|
392
|
+
"--name",
|
|
393
|
+
_BUILDER,
|
|
394
|
+
"--driver",
|
|
395
|
+
"docker-container",
|
|
396
|
+
"--driver-opt",
|
|
397
|
+
f"image={_BUILDKIT_DRIVER_IMAGE}",
|
|
398
|
+
],
|
|
399
|
+
timeout=60,
|
|
400
|
+
)
|
|
401
|
+
_BUILD_STATE["builder"] = True
|
|
402
|
+
return _BUILDER
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _build_with_run_builder(
|
|
406
|
+
docker: str,
|
|
407
|
+
image: str,
|
|
408
|
+
dockerfile: str,
|
|
409
|
+
context: Path,
|
|
410
|
+
) -> None:
|
|
411
|
+
"""Build one image on the run's shared builder, pulling bases once."""
|
|
412
|
+
builder = _run_builder(docker)
|
|
413
|
+
with _BUILD_LOCK:
|
|
414
|
+
pulled = _BUILD_STATE["pulled"]
|
|
415
|
+
assert isinstance(pulled, set)
|
|
416
|
+
fresh = [
|
|
417
|
+
reference
|
|
418
|
+
for reference in _base_image_refs(dockerfile)
|
|
419
|
+
if reference not in pulled
|
|
420
|
+
]
|
|
421
|
+
pulled.update(fresh)
|
|
422
|
+
_run(
|
|
423
|
+
[docker, "image", "rm", "--force", image],
|
|
424
|
+
timeout=60,
|
|
425
|
+
check=False,
|
|
426
|
+
)
|
|
427
|
+
build = [docker, "buildx", "build", "--builder", builder]
|
|
428
|
+
if fresh:
|
|
429
|
+
# Recipe and harness tags are pinned, so one pull per run is enough.
|
|
430
|
+
build.append("--pull")
|
|
431
|
+
build.extend(
|
|
432
|
+
[
|
|
433
|
+
"--label",
|
|
434
|
+
MANAGED_LABEL,
|
|
435
|
+
"--label",
|
|
436
|
+
RUN_LABEL,
|
|
437
|
+
"--load",
|
|
438
|
+
"--tag",
|
|
439
|
+
image,
|
|
440
|
+
str(context),
|
|
441
|
+
]
|
|
442
|
+
)
|
|
443
|
+
_run(build, timeout=1800)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _remove_labelled(docker: str, errors: list[str]) -> None:
|
|
447
|
+
"""Remove every container, network, volume, and image of this run."""
|
|
448
|
+
queries = (
|
|
449
|
+
(
|
|
450
|
+
"containers",
|
|
451
|
+
[docker, "ps", "--all", "--quiet", "--filter", f"label={RUN_LABEL}"],
|
|
452
|
+
lambda ids: [docker, "rm", "--force", *ids],
|
|
453
|
+
),
|
|
454
|
+
(
|
|
455
|
+
"networks",
|
|
456
|
+
[docker, "network", "ls", "--quiet", "--filter", f"label={RUN_LABEL}"],
|
|
457
|
+
lambda ids: [docker, "network", "rm", *ids],
|
|
458
|
+
),
|
|
459
|
+
(
|
|
460
|
+
"volumes",
|
|
461
|
+
[docker, "volume", "ls", "--quiet", "--filter", f"label={RUN_LABEL}"],
|
|
462
|
+
lambda ids: [docker, "volume", "rm", "--force", *ids],
|
|
463
|
+
),
|
|
464
|
+
(
|
|
465
|
+
"images",
|
|
466
|
+
[docker, "image", "ls", "--quiet", "--filter", f"label={RUN_LABEL}"],
|
|
467
|
+
lambda ids: [docker, "image", "rm", "--force", *ids],
|
|
468
|
+
),
|
|
469
|
+
)
|
|
470
|
+
for kind, query, removal in queries:
|
|
471
|
+
try:
|
|
472
|
+
listed = _run(query, timeout=30, check=False)
|
|
473
|
+
except Exception as exc:
|
|
474
|
+
errors.append(f"could not inspect run {kind}: {exc}")
|
|
475
|
+
continue
|
|
476
|
+
if listed.returncode:
|
|
477
|
+
detail = _tail(listed.stderr or listed.stdout) or "unknown Docker error"
|
|
478
|
+
errors.append(f"could not inspect run {kind}: {detail}")
|
|
479
|
+
continue
|
|
480
|
+
identifiers = sorted(set(listed.stdout.split()))
|
|
481
|
+
if not identifiers:
|
|
482
|
+
continue
|
|
483
|
+
with contextlib.suppress(Exception):
|
|
484
|
+
_run(removal(identifiers), timeout=120, check=False)
|
|
485
|
+
remaining = _run(query, timeout=30, check=False)
|
|
486
|
+
if remaining.returncode == 0 and remaining.stdout.split():
|
|
487
|
+
errors.append(
|
|
488
|
+
f"run {kind} remain: "
|
|
489
|
+
+ ", ".join(sorted(set(remaining.stdout.split())))
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def cleanup_run_resources(docker: str | None = None) -> None:
|
|
494
|
+
"""Tear down every Docker resource this run created, and nothing else."""
|
|
495
|
+
global _MC_ARENA_READY
|
|
496
|
+
docker = docker or _docker_binary()
|
|
497
|
+
errors: list[str] = []
|
|
498
|
+
# The mc-arena image carries this run's label, so the cleanup below deletes
|
|
499
|
+
# it. Forget that it was built, or a later job in the same process hands
|
|
500
|
+
# `docker run` a tag that no longer exists and every task is a harness
|
|
501
|
+
# error.
|
|
502
|
+
with _MC_ARENA_LOCK:
|
|
503
|
+
_MC_ARENA_READY = False
|
|
504
|
+
with _BUILD_LOCK:
|
|
505
|
+
builder_created = bool(_BUILD_STATE["builder"])
|
|
506
|
+
driver_was_present = bool(_BUILD_STATE["driver_was_present"])
|
|
507
|
+
_BUILD_STATE["builder"] = False
|
|
508
|
+
pulled = _BUILD_STATE["pulled"]
|
|
509
|
+
assert isinstance(pulled, set)
|
|
510
|
+
pulled.clear()
|
|
511
|
+
if builder_created:
|
|
512
|
+
# Removing the builder drops its BuildKit state volume, and with it
|
|
513
|
+
# every layer and uv cache entry the run created.
|
|
514
|
+
cleanup_commands = (
|
|
515
|
+
([docker, "buildx", "rm", "--force", _BUILDER], 60),
|
|
516
|
+
([docker, "rm", "--force", _BUILDKIT_CONTAINER], 30),
|
|
517
|
+
([docker, "volume", "rm", "--force", _BUILDKIT_VOLUME], 30),
|
|
518
|
+
)
|
|
519
|
+
for command, timeout in cleanup_commands:
|
|
520
|
+
with contextlib.suppress(Exception):
|
|
521
|
+
_run(command, timeout=timeout, check=False)
|
|
522
|
+
if not driver_was_present:
|
|
523
|
+
with contextlib.suppress(Exception):
|
|
524
|
+
_run(
|
|
525
|
+
[docker, "image", "rm", "--force", _BUILDKIT_DRIVER_IMAGE],
|
|
526
|
+
timeout=60,
|
|
527
|
+
check=False,
|
|
528
|
+
)
|
|
529
|
+
_remove_labelled(docker, errors)
|
|
530
|
+
if builder_created:
|
|
531
|
+
_verify_absent(
|
|
532
|
+
[docker, "buildx", "inspect", _BUILDER],
|
|
533
|
+
f"Buildx builder {_BUILDER}",
|
|
534
|
+
errors,
|
|
535
|
+
)
|
|
536
|
+
_verify_absent(
|
|
537
|
+
[docker, "container", "inspect", _BUILDKIT_CONTAINER],
|
|
538
|
+
f"BuildKit container {_BUILDKIT_CONTAINER}",
|
|
539
|
+
errors,
|
|
540
|
+
)
|
|
541
|
+
_verify_absent(
|
|
542
|
+
[docker, "volume", "inspect", _BUILDKIT_VOLUME],
|
|
543
|
+
f"BuildKit volume {_BUILDKIT_VOLUME}",
|
|
544
|
+
errors,
|
|
545
|
+
)
|
|
546
|
+
if not driver_was_present:
|
|
547
|
+
_verify_absent(
|
|
548
|
+
[docker, "image", "inspect", _BUILDKIT_DRIVER_IMAGE],
|
|
549
|
+
f"BuildKit driver image {_BUILDKIT_DRIVER_IMAGE}",
|
|
550
|
+
errors,
|
|
551
|
+
)
|
|
552
|
+
if errors:
|
|
553
|
+
raise SandboxError("Pi Docker cleanup failed: " + "; ".join(errors))
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
MC_ARENA_IMAGE = f"benchkit-mc-arena:{_MC_ARENA_IMAGE_SESSION}"
|
|
557
|
+
# Enough to diagnose a traceback; a script cannot buy more by printing more.
|
|
558
|
+
STDERR_LIMIT = 256 * 1024
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
_MC_ARENA_READY = False
|
|
562
|
+
_MC_ARENA_USERS = 0
|
|
563
|
+
_MC_ARENA_LOCK = threading.Lock()
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def run_resources_created() -> bool:
|
|
567
|
+
"""Whether this process built anything the run-level sweep must remove."""
|
|
568
|
+
with _BUILD_LOCK:
|
|
569
|
+
return bool(_BUILD_STATE["builder"])
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
@contextlib.contextmanager
|
|
573
|
+
def _mc_arena_image_lease():
|
|
574
|
+
"""Hold the mc-arena image for one script; the last holder removes it.
|
|
575
|
+
|
|
576
|
+
Each task builds the image (a cache hit on the run's builder), runs its
|
|
577
|
+
script, and deletes it, so a run never keeps the image between tasks.
|
|
578
|
+
"""
|
|
579
|
+
global _MC_ARENA_USERS, _MC_ARENA_READY
|
|
580
|
+
with _MC_ARENA_LOCK:
|
|
581
|
+
_MC_ARENA_USERS += 1
|
|
582
|
+
try:
|
|
583
|
+
yield mc_arena_image()
|
|
584
|
+
finally:
|
|
585
|
+
with _MC_ARENA_LOCK:
|
|
586
|
+
_MC_ARENA_USERS -= 1
|
|
587
|
+
release = (
|
|
588
|
+
_MC_ARENA_USERS == 0
|
|
589
|
+
and _MC_ARENA_READY
|
|
590
|
+
and not os.environ.get("BENCHKIT_MC_ARENA_IMAGE", "").strip()
|
|
591
|
+
)
|
|
592
|
+
if release:
|
|
593
|
+
_MC_ARENA_READY = False
|
|
594
|
+
with contextlib.suppress(Exception):
|
|
595
|
+
_run(
|
|
596
|
+
[_docker_binary(), "image", "rm", "--force", MC_ARENA_IMAGE],
|
|
597
|
+
timeout=60,
|
|
598
|
+
check=False,
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def mc_arena_image() -> str:
|
|
603
|
+
"""Build the mc-arena script runner once per process and return its tag.
|
|
604
|
+
|
|
605
|
+
``BENCHKIT_MC_ARENA_IMAGE`` names an already-built image instead, for hosts
|
|
606
|
+
that cannot build one (no registry route, no build network). It has to
|
|
607
|
+
provide ``uv`` on PATH and a writable ``/work``.
|
|
608
|
+
"""
|
|
609
|
+
prebuilt = os.environ.get("BENCHKIT_MC_ARENA_IMAGE", "").strip()
|
|
610
|
+
if prebuilt:
|
|
611
|
+
return prebuilt
|
|
612
|
+
|
|
613
|
+
global _MC_ARENA_READY
|
|
614
|
+
if _MC_ARENA_READY:
|
|
615
|
+
return MC_ARENA_IMAGE
|
|
616
|
+
docker = _docker_binary()
|
|
617
|
+
with _MC_ARENA_LOCK:
|
|
618
|
+
if _MC_ARENA_READY:
|
|
619
|
+
return MC_ARENA_IMAGE
|
|
620
|
+
_run([docker, "version", "--format", "{{.Server.Version}}"], timeout=10)
|
|
621
|
+
dockerfile = mc_arena_dockerfile()
|
|
622
|
+
with tempfile.TemporaryDirectory(prefix="benchkit-mc-arena-build-") as name:
|
|
623
|
+
context = Path(name)
|
|
624
|
+
(context / "Dockerfile").write_text(dockerfile, encoding="utf-8")
|
|
625
|
+
_build_with_run_builder(docker, MC_ARENA_IMAGE, dockerfile, context)
|
|
626
|
+
_MC_ARENA_READY = True
|
|
627
|
+
return MC_ARENA_IMAGE
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
@dataclass
|
|
631
|
+
class _CappedStream:
|
|
632
|
+
"""Drain one pipe in a thread, keeping at most ``limit`` characters.
|
|
633
|
+
|
|
634
|
+
Truncating after the fact does not help against a script that prints
|
|
635
|
+
forever: the whole stream would already be sitting in this process's
|
|
636
|
+
memory, where the container's own memory limit does not reach. Everything
|
|
637
|
+
past the cap is still read and dropped, so the writer never blocks on a
|
|
638
|
+
full pipe while its deadline runs out.
|
|
639
|
+
"""
|
|
640
|
+
|
|
641
|
+
stream: object
|
|
642
|
+
limit: int
|
|
643
|
+
chunks: list[str] = field(default_factory=list)
|
|
644
|
+
kept: int = 0
|
|
645
|
+
truncated: bool = False
|
|
646
|
+
|
|
647
|
+
def pump(self) -> None:
|
|
648
|
+
try:
|
|
649
|
+
while True:
|
|
650
|
+
chunk = self.stream.read(65536)
|
|
651
|
+
if not chunk:
|
|
652
|
+
break
|
|
653
|
+
if self.kept >= self.limit:
|
|
654
|
+
self.truncated = True
|
|
655
|
+
continue
|
|
656
|
+
keep = chunk[: self.limit - self.kept]
|
|
657
|
+
self.chunks.append(keep)
|
|
658
|
+
self.kept += len(keep)
|
|
659
|
+
if len(keep) < len(chunk):
|
|
660
|
+
self.truncated = True
|
|
661
|
+
except (OSError, ValueError): # pragma: no cover - pipe torn down
|
|
662
|
+
pass
|
|
663
|
+
finally:
|
|
664
|
+
with contextlib.suppress(Exception):
|
|
665
|
+
self.stream.close()
|
|
666
|
+
|
|
667
|
+
@property
|
|
668
|
+
def text(self) -> str:
|
|
669
|
+
return "".join(self.chunks)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _run_capped(
|
|
673
|
+
args: list[str],
|
|
674
|
+
*,
|
|
675
|
+
timeout: float,
|
|
676
|
+
stdout_limit: int,
|
|
677
|
+
stderr_limit: int,
|
|
678
|
+
) -> tuple[int, _CappedStream, _CappedStream]:
|
|
679
|
+
"""Run a command, keeping only bounded prefixes of its two streams."""
|
|
680
|
+
try:
|
|
681
|
+
process = subprocess.Popen(
|
|
682
|
+
args,
|
|
683
|
+
stdout=subprocess.PIPE,
|
|
684
|
+
stderr=subprocess.PIPE,
|
|
685
|
+
text=True,
|
|
686
|
+
errors="replace",
|
|
687
|
+
)
|
|
688
|
+
except OSError as exc:
|
|
689
|
+
raise SandboxError(f"Could not run Docker: {exc}") from exc
|
|
690
|
+
|
|
691
|
+
out = _CappedStream(process.stdout, stdout_limit)
|
|
692
|
+
err = _CappedStream(process.stderr, stderr_limit)
|
|
693
|
+
pumps = [threading.Thread(target=stream.pump, daemon=True) for stream in (out, err)]
|
|
694
|
+
for pump in pumps:
|
|
695
|
+
pump.start()
|
|
696
|
+
try:
|
|
697
|
+
process.wait(timeout=timeout)
|
|
698
|
+
except subprocess.TimeoutExpired:
|
|
699
|
+
process.kill()
|
|
700
|
+
process.wait()
|
|
701
|
+
for pump in pumps:
|
|
702
|
+
pump.join(timeout=10)
|
|
703
|
+
return process.returncode, out, err
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
@dataclass(frozen=True)
|
|
707
|
+
class ScriptRun:
|
|
708
|
+
"""One execution of a model-written script inside the sandbox."""
|
|
709
|
+
|
|
710
|
+
exit_code: int
|
|
711
|
+
stdout: str
|
|
712
|
+
stderr: str
|
|
713
|
+
timed_out: bool = False
|
|
714
|
+
# Set when the script printed more than the sandbox was willing to keep.
|
|
715
|
+
stdout_truncated: bool = False
|
|
716
|
+
|
|
717
|
+
@property
|
|
718
|
+
def ok(self) -> bool:
|
|
719
|
+
return self.exit_code == 0 and not self.timed_out
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def run_python_script(
|
|
723
|
+
code: str,
|
|
724
|
+
*,
|
|
725
|
+
timeout_s: float = 30.0,
|
|
726
|
+
stdout_limit: int = 8 * 1024 * 1024,
|
|
727
|
+
) -> ScriptRun:
|
|
728
|
+
"""Run one untrusted Python script in a throwaway, network-less container.
|
|
729
|
+
|
|
730
|
+
The container has no network, no capabilities and a read-only root
|
|
731
|
+
filesystem with small writable tmpfs mounts, plus hard memory, process and
|
|
732
|
+
time limits: a script that forks, allocates or spins is bounded by the
|
|
733
|
+
sandbox rather than by anything the script itself agrees to.
|
|
734
|
+
"""
|
|
735
|
+
with _mc_arena_image_lease() as image:
|
|
736
|
+
return _run_python_script_in(
|
|
737
|
+
image, code, timeout_s=timeout_s, stdout_limit=stdout_limit
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _run_python_script_in(
|
|
742
|
+
image: str,
|
|
743
|
+
code: str,
|
|
744
|
+
*,
|
|
745
|
+
timeout_s: float,
|
|
746
|
+
stdout_limit: int,
|
|
747
|
+
) -> ScriptRun:
|
|
748
|
+
docker = _docker_binary()
|
|
749
|
+
container = f"benchkit-mc-arena-{uuid.uuid4().hex[:12]}"
|
|
750
|
+
memory = os.environ.get("BENCHKIT_MC_ARENA_MEMORY", "512m")
|
|
751
|
+
cpus = os.environ.get("BENCHKIT_MC_ARENA_CPUS", "1")
|
|
752
|
+
pids = os.environ.get("BENCHKIT_MC_ARENA_PIDS", "64")
|
|
753
|
+
|
|
754
|
+
try:
|
|
755
|
+
_run(
|
|
756
|
+
[
|
|
757
|
+
docker,
|
|
758
|
+
"run",
|
|
759
|
+
"--detach",
|
|
760
|
+
"--name",
|
|
761
|
+
container,
|
|
762
|
+
*resource_labels(),
|
|
763
|
+
"--network",
|
|
764
|
+
"none",
|
|
765
|
+
"--cap-drop",
|
|
766
|
+
"ALL",
|
|
767
|
+
"--security-opt",
|
|
768
|
+
"no-new-privileges",
|
|
769
|
+
"--read-only",
|
|
770
|
+
"--tmpfs",
|
|
771
|
+
"/tmp:size=64m",
|
|
772
|
+
"--tmpfs",
|
|
773
|
+
"/home/runner:size=64m,uid=1000,gid=1000",
|
|
774
|
+
"--tmpfs",
|
|
775
|
+
"/work:size=16m,uid=1000,gid=1000",
|
|
776
|
+
"--pids-limit",
|
|
777
|
+
pids,
|
|
778
|
+
"--memory",
|
|
779
|
+
memory,
|
|
780
|
+
"--cpus",
|
|
781
|
+
cpus,
|
|
782
|
+
image,
|
|
783
|
+
"sleep",
|
|
784
|
+
"infinity",
|
|
785
|
+
],
|
|
786
|
+
timeout=120,
|
|
787
|
+
)
|
|
788
|
+
# `docker cp` refuses a read-only rootfs even when the destination is a
|
|
789
|
+
# tmpfs, so the script is written from inside the container instead.
|
|
790
|
+
_run(
|
|
791
|
+
[docker, "exec", "--interactive", container, "tee", "/work/build.py"],
|
|
792
|
+
input_text=code,
|
|
793
|
+
timeout=60,
|
|
794
|
+
)
|
|
795
|
+
# Read the streams under a hard cap rather than buffering whatever the
|
|
796
|
+
# script decides to print: the container's memory limit bounds the
|
|
797
|
+
# script, not this process's copy of its output.
|
|
798
|
+
code_out, out, err = _run_capped(
|
|
799
|
+
[
|
|
800
|
+
docker,
|
|
801
|
+
"exec",
|
|
802
|
+
"--workdir",
|
|
803
|
+
"/work",
|
|
804
|
+
container,
|
|
805
|
+
"timeout",
|
|
806
|
+
"--signal=KILL",
|
|
807
|
+
f"{timeout_s:g}s",
|
|
808
|
+
"uv",
|
|
809
|
+
"run",
|
|
810
|
+
"--no-project",
|
|
811
|
+
"--offline",
|
|
812
|
+
"build.py",
|
|
813
|
+
],
|
|
814
|
+
timeout=timeout_s + 60,
|
|
815
|
+
stdout_limit=stdout_limit,
|
|
816
|
+
stderr_limit=STDERR_LIMIT,
|
|
817
|
+
)
|
|
818
|
+
finally:
|
|
819
|
+
with contextlib.suppress(Exception):
|
|
820
|
+
_run([docker, "rm", "--force", container], timeout=60, check=False)
|
|
821
|
+
|
|
822
|
+
return ScriptRun(
|
|
823
|
+
exit_code=code_out,
|
|
824
|
+
stdout=out.text,
|
|
825
|
+
stdout_truncated=out.truncated,
|
|
826
|
+
stderr=_tail(err.text, 8000),
|
|
827
|
+
# `timeout` reports 137 for the KILL it sends once the deadline passes.
|
|
828
|
+
timed_out=code_out == 137,
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def aider_pi_image() -> LatestPiImage:
|
|
833
|
+
"""Return an image definition containing every Aider Polyglot toolchain."""
|
|
834
|
+
return LatestPiImage(
|
|
835
|
+
image=AIDER_PI_IMAGE,
|
|
836
|
+
dockerfile=AIDER_PI_DOCKERFILE,
|
|
837
|
+
transient=True,
|
|
838
|
+
# cpp/bank-account creates 1,000 simultaneous std::threads. Linux
|
|
839
|
+
# accounts threads against Docker's PID cgroup, so the generic Pi
|
|
840
|
+
# sandbox limit of 256 makes the official test suite impossible.
|
|
841
|
+
pids_limit=2048,
|
|
842
|
+
answer_key_guard=True,
|
|
843
|
+
resource_scope="aider-polyglot",
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def git_surgery_pi_image() -> LatestPiImage:
|
|
848
|
+
"""Return the stock Pi image with pinned Git and Git Surgery assets."""
|
|
849
|
+
return LatestPiImage(
|
|
850
|
+
image=GIT_SURGERY_PI_IMAGE,
|
|
851
|
+
dockerfile=GIT_SURGERY_PI_DOCKERFILE,
|
|
852
|
+
transient=True,
|
|
853
|
+
build_assets=Path(__file__).with_name("git_surgery"),
|
|
854
|
+
resource_scope="git-surgery",
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def _regular_file_sha256(path: Path) -> str:
|
|
859
|
+
digest = hashlib.sha256()
|
|
860
|
+
with path.open("rb") as handle:
|
|
861
|
+
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
|
862
|
+
digest.update(block)
|
|
863
|
+
return digest.hexdigest()
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _tree_sha256(root: Path) -> str:
|
|
867
|
+
digest = hashlib.sha256()
|
|
868
|
+
for path in sorted(root.rglob("*")):
|
|
869
|
+
if path.is_symlink():
|
|
870
|
+
raise ValueError("Pi package build assets must not contain symlinks")
|
|
871
|
+
if not path.is_file():
|
|
872
|
+
continue
|
|
873
|
+
relative = path.relative_to(root).as_posix().encode()
|
|
874
|
+
digest.update(len(relative).to_bytes(8, "big"))
|
|
875
|
+
digest.update(relative)
|
|
876
|
+
digest.update(bytes.fromhex(_regular_file_sha256(path)))
|
|
877
|
+
return digest.hexdigest()
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def patcheval_pi_image(
|
|
881
|
+
source_archive: Path,
|
|
882
|
+
source_sha256: str,
|
|
883
|
+
recipe: PatchEvalRuntimeRecipe,
|
|
884
|
+
) -> LatestPiImage:
|
|
885
|
+
"""Build one task runtime locally without retaining buildkit state."""
|
|
886
|
+
if source_archive.is_symlink() or not source_archive.is_file():
|
|
887
|
+
raise ValueError("PatchEval source archive must be a regular file")
|
|
888
|
+
if not re.fullmatch(r"[0-9a-f]{64}", source_sha256):
|
|
889
|
+
raise ValueError("PatchEval source_sha256 must be a lowercase SHA-256")
|
|
890
|
+
if _regular_file_sha256(source_archive) != source_sha256:
|
|
891
|
+
raise ValueError("PatchEval source archive checksum mismatch")
|
|
892
|
+
# Hash a run-independent Dockerfile so the same task keeps one identity
|
|
893
|
+
# across runs even though the uv cache mount is run-scoped.
|
|
894
|
+
dockerfile = patcheval_dockerfile(recipe, _UV_CACHE_ID)
|
|
895
|
+
identity_dockerfile = patcheval_dockerfile(recipe, "benchkit-uv")
|
|
896
|
+
recipe_json = json.dumps(
|
|
897
|
+
{
|
|
898
|
+
"schema_version": recipe.schema_version,
|
|
899
|
+
"base_image": recipe.base_image,
|
|
900
|
+
"sync_command": list(recipe.sync_command),
|
|
901
|
+
"bootstrap_command": list(recipe.bootstrap_command),
|
|
902
|
+
"environment": list(recipe.environment),
|
|
903
|
+
},
|
|
904
|
+
sort_keys=True,
|
|
905
|
+
separators=(",", ":"),
|
|
906
|
+
)
|
|
907
|
+
identity = hashlib.sha256()
|
|
908
|
+
for value in (
|
|
909
|
+
identity_dockerfile,
|
|
910
|
+
source_sha256,
|
|
911
|
+
recipe_json,
|
|
912
|
+
_tree_sha256(_PI_PACKAGE_ROOT),
|
|
913
|
+
_regular_file_sha256(_PROXY_SOURCE),
|
|
914
|
+
_regular_file_sha256(_ANSWER_KEY_GUARD_SOURCE),
|
|
915
|
+
):
|
|
916
|
+
identity.update(value.encode())
|
|
917
|
+
identity.update(b"\0")
|
|
918
|
+
image_id = identity.hexdigest()[:16]
|
|
919
|
+
return LatestPiImage(
|
|
920
|
+
image=f"benchkit-pi-patcheval:{_PATCHEVAL_IMAGE_SESSION}-{image_id}",
|
|
921
|
+
dockerfile=dockerfile,
|
|
922
|
+
transient=True,
|
|
923
|
+
build_files=((source_archive, "parent-source.tar"),),
|
|
924
|
+
always_cleanup_image=True,
|
|
925
|
+
resource_scope="patcheval",
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
@dataclass
|
|
930
|
+
class DockerTaskEnvironment:
|
|
931
|
+
"""One isolated, persistent workspace shared by every native Pi tool call."""
|
|
932
|
+
|
|
933
|
+
client: InferenceClient
|
|
934
|
+
model: str
|
|
935
|
+
image: LatestPiImage
|
|
936
|
+
docker: str = field(default_factory=_docker_binary)
|
|
937
|
+
owner_id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
|
938
|
+
workdir: str = "/workspace"
|
|
939
|
+
task_name: str = field(default_factory=lambda: f"benchkit-{uuid.uuid4().hex[:12]}")
|
|
940
|
+
network_name: str = field(init=False)
|
|
941
|
+
proxy_name: str = field(init=False)
|
|
942
|
+
container_name: str = field(init=False)
|
|
943
|
+
_started: bool = field(default=False, init=False, repr=False)
|
|
944
|
+
|
|
945
|
+
def __post_init__(self) -> None:
|
|
946
|
+
self.network_name = f"{self.task_name}-net"
|
|
947
|
+
self.proxy_name = f"{self.task_name}-proxy"
|
|
948
|
+
self.container_name = f"{self.task_name}-agent"
|
|
949
|
+
|
|
950
|
+
def __enter__(self) -> DockerTaskEnvironment:
|
|
951
|
+
self.start()
|
|
952
|
+
return self
|
|
953
|
+
|
|
954
|
+
def __exit__(self, *_args: object) -> None:
|
|
955
|
+
self.stop()
|
|
956
|
+
|
|
957
|
+
def start(self) -> None:
|
|
958
|
+
if self._started:
|
|
959
|
+
return
|
|
960
|
+
if not self.image.version:
|
|
961
|
+
raise SandboxError("Pi image must be prepared before starting a task")
|
|
962
|
+
try:
|
|
963
|
+
labels = resource_labels(self.owner_id)
|
|
964
|
+
_run(
|
|
965
|
+
[
|
|
966
|
+
self.docker,
|
|
967
|
+
"network",
|
|
968
|
+
"create",
|
|
969
|
+
"--internal",
|
|
970
|
+
*labels,
|
|
971
|
+
self.network_name,
|
|
972
|
+
]
|
|
973
|
+
)
|
|
974
|
+
proxy_args = [
|
|
975
|
+
self.docker,
|
|
976
|
+
"run",
|
|
977
|
+
"--detach",
|
|
978
|
+
"--name",
|
|
979
|
+
self.proxy_name,
|
|
980
|
+
*labels,
|
|
981
|
+
"--network",
|
|
982
|
+
self.network_name,
|
|
983
|
+
"--network-alias",
|
|
984
|
+
"inference",
|
|
985
|
+
"--add-host",
|
|
986
|
+
"host.docker.internal:host-gateway",
|
|
987
|
+
"--cap-drop",
|
|
988
|
+
"ALL",
|
|
989
|
+
"--security-opt",
|
|
990
|
+
"no-new-privileges",
|
|
991
|
+
"--pids-limit",
|
|
992
|
+
"64",
|
|
993
|
+
"--memory",
|
|
994
|
+
"256m",
|
|
995
|
+
"--env",
|
|
996
|
+
f"BENCHKIT_UPSTREAM={_docker_upstream(self.client.host)}",
|
|
997
|
+
"--env",
|
|
998
|
+
f"BENCHKIT_MODEL={self.model}",
|
|
999
|
+
"--env",
|
|
1000
|
+
f"BENCHKIT_UPSTREAM_TIMEOUT={self.client.timeout}",
|
|
1001
|
+
]
|
|
1002
|
+
if self.client.api_key:
|
|
1003
|
+
proxy_args.extend(
|
|
1004
|
+
["--env", f"BENCHKIT_UPSTREAM_API_KEY={self.client.api_key}"]
|
|
1005
|
+
)
|
|
1006
|
+
proxy_args.extend(
|
|
1007
|
+
[self.image.image, "python3", "/opt/benchkit/inference_proxy.py"]
|
|
1008
|
+
)
|
|
1009
|
+
_run(proxy_args)
|
|
1010
|
+
_run([self.docker, "network", "connect", "bridge", self.proxy_name])
|
|
1011
|
+
|
|
1012
|
+
memory = os.environ.get("BENCHKIT_SANDBOX_MEMORY", "2g")
|
|
1013
|
+
cpus = os.environ.get("BENCHKIT_SANDBOX_CPUS", "2")
|
|
1014
|
+
pids = os.environ.get("BENCHKIT_SANDBOX_PIDS", str(self.image.pids_limit))
|
|
1015
|
+
_run(
|
|
1016
|
+
[
|
|
1017
|
+
self.docker,
|
|
1018
|
+
"run",
|
|
1019
|
+
"--detach",
|
|
1020
|
+
"--name",
|
|
1021
|
+
self.container_name,
|
|
1022
|
+
*labels,
|
|
1023
|
+
"--network",
|
|
1024
|
+
self.network_name,
|
|
1025
|
+
"--cap-drop",
|
|
1026
|
+
"ALL",
|
|
1027
|
+
"--security-opt",
|
|
1028
|
+
"no-new-privileges",
|
|
1029
|
+
"--pids-limit",
|
|
1030
|
+
pids,
|
|
1031
|
+
"--memory",
|
|
1032
|
+
memory,
|
|
1033
|
+
"--cpus",
|
|
1034
|
+
cpus,
|
|
1035
|
+
self.image.image,
|
|
1036
|
+
]
|
|
1037
|
+
)
|
|
1038
|
+
self._write_model_config()
|
|
1039
|
+
self._started = True
|
|
1040
|
+
except Exception:
|
|
1041
|
+
self.stop()
|
|
1042
|
+
raise
|
|
1043
|
+
|
|
1044
|
+
def _write_model_config(self) -> None:
|
|
1045
|
+
context_length = None
|
|
1046
|
+
discover = getattr(self.client, "context_length", None)
|
|
1047
|
+
if callable(discover):
|
|
1048
|
+
context_length = discover(self.model)
|
|
1049
|
+
model: dict[str, object] = {"id": self.model, "name": self.model}
|
|
1050
|
+
if isinstance(context_length, int) and context_length > 0:
|
|
1051
|
+
model["contextWindow"] = context_length
|
|
1052
|
+
|
|
1053
|
+
provider: dict[str, object] = {
|
|
1054
|
+
"baseUrl": "http://inference:8080/v1",
|
|
1055
|
+
"api": "openai-completions",
|
|
1056
|
+
"apiKey": "benchkit-sandbox",
|
|
1057
|
+
"models": [model],
|
|
1058
|
+
}
|
|
1059
|
+
if self.client.provider == "ollama":
|
|
1060
|
+
provider["compat"] = {
|
|
1061
|
+
"supportsDeveloperRole": False,
|
|
1062
|
+
"supportsReasoningEffort": False,
|
|
1063
|
+
}
|
|
1064
|
+
payload = json.dumps({"providers": {"benchkit": provider}})
|
|
1065
|
+
_run(
|
|
1066
|
+
[
|
|
1067
|
+
self.docker,
|
|
1068
|
+
"exec",
|
|
1069
|
+
"--interactive",
|
|
1070
|
+
self.container_name,
|
|
1071
|
+
"tee",
|
|
1072
|
+
"/home/node/.pi/agent/models.json",
|
|
1073
|
+
],
|
|
1074
|
+
input_text=payload,
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
def start_pi(self) -> subprocess.Popen[str]:
|
|
1078
|
+
if not self._started:
|
|
1079
|
+
raise SandboxError("task environment is not running")
|
|
1080
|
+
command = [
|
|
1081
|
+
self.docker,
|
|
1082
|
+
"exec",
|
|
1083
|
+
"--interactive",
|
|
1084
|
+
"--workdir",
|
|
1085
|
+
self.workdir,
|
|
1086
|
+
self.container_name,
|
|
1087
|
+
"pi",
|
|
1088
|
+
"--mode",
|
|
1089
|
+
"rpc",
|
|
1090
|
+
"--no-session",
|
|
1091
|
+
"--provider",
|
|
1092
|
+
"benchkit",
|
|
1093
|
+
"--model",
|
|
1094
|
+
self.model,
|
|
1095
|
+
]
|
|
1096
|
+
if self.image.answer_key_guard:
|
|
1097
|
+
command.extend(["--extension", "/opt/benchkit/answer_key_guard.ts"])
|
|
1098
|
+
return subprocess.Popen(
|
|
1099
|
+
command,
|
|
1100
|
+
stdin=subprocess.PIPE,
|
|
1101
|
+
stdout=subprocess.PIPE,
|
|
1102
|
+
stderr=subprocess.PIPE,
|
|
1103
|
+
text=True,
|
|
1104
|
+
bufsize=1,
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
def pi_scaffold(self) -> dict:
|
|
1108
|
+
"""Read scaffold metadata captured from Pi's first inference request."""
|
|
1109
|
+
result = _run(
|
|
1110
|
+
[
|
|
1111
|
+
self.docker,
|
|
1112
|
+
"exec",
|
|
1113
|
+
self.proxy_name,
|
|
1114
|
+
"cat",
|
|
1115
|
+
"/tmp/benchkit_pi_scaffold.json",
|
|
1116
|
+
],
|
|
1117
|
+
timeout=10,
|
|
1118
|
+
check=False,
|
|
1119
|
+
)
|
|
1120
|
+
if result.returncode:
|
|
1121
|
+
return {}
|
|
1122
|
+
try:
|
|
1123
|
+
payload = json.loads(result.stdout)
|
|
1124
|
+
except (json.JSONDecodeError, TypeError):
|
|
1125
|
+
return {}
|
|
1126
|
+
return payload if isinstance(payload, dict) else {}
|
|
1127
|
+
|
|
1128
|
+
def exec(
|
|
1129
|
+
self,
|
|
1130
|
+
command: list[str],
|
|
1131
|
+
*,
|
|
1132
|
+
workdir: str | None = None,
|
|
1133
|
+
input_text: str | None = None,
|
|
1134
|
+
timeout: float | None = None,
|
|
1135
|
+
check: bool = True,
|
|
1136
|
+
) -> subprocess.CompletedProcess[str]:
|
|
1137
|
+
return _run(
|
|
1138
|
+
[
|
|
1139
|
+
self.docker,
|
|
1140
|
+
"exec",
|
|
1141
|
+
*(["--workdir", workdir] if workdir is not None else []),
|
|
1142
|
+
self.container_name,
|
|
1143
|
+
*command,
|
|
1144
|
+
],
|
|
1145
|
+
input_text=input_text,
|
|
1146
|
+
timeout=timeout,
|
|
1147
|
+
check=check,
|
|
1148
|
+
)
|
|
1149
|
+
|
|
1150
|
+
def upload(self, source: Path, destination: str) -> None:
|
|
1151
|
+
_run([self.docker, "cp", str(source), f"{self.container_name}:{destination}"])
|
|
1152
|
+
|
|
1153
|
+
def download(self, source: str, destination: Path) -> None:
|
|
1154
|
+
_run([self.docker, "cp", f"{self.container_name}:{source}", str(destination)])
|
|
1155
|
+
|
|
1156
|
+
def stop(self) -> None:
|
|
1157
|
+
try:
|
|
1158
|
+
commands = [
|
|
1159
|
+
[self.docker, "rm", "--force", self.container_name],
|
|
1160
|
+
[self.docker, "rm", "--force", self.proxy_name],
|
|
1161
|
+
[self.docker, "network", "rm", self.network_name],
|
|
1162
|
+
]
|
|
1163
|
+
for command in commands:
|
|
1164
|
+
with contextlib.suppress(Exception):
|
|
1165
|
+
_run(command, timeout=30, check=False)
|
|
1166
|
+
finally:
|
|
1167
|
+
self._started = False
|