coder-eval 0.8.2__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.
- coder_eval/.gitattributes +2 -0
- coder_eval/__init__.py +3 -0
- coder_eval/agent.py +302 -0
- coder_eval/agents/__init__.py +41 -0
- coder_eval/agents/_logging.py +89 -0
- coder_eval/agents/antigravity_agent.py +811 -0
- coder_eval/agents/claude_code_agent.py +1701 -0
- coder_eval/agents/codex_agent.py +2055 -0
- coder_eval/agents/noop_agent.py +100 -0
- coder_eval/agents/registry.py +163 -0
- coder_eval/agents/watchdog.py +116 -0
- coder_eval/analysis.py +88 -0
- coder_eval/cli/__init__.py +87 -0
- coder_eval/cli/aggregate_command.py +153 -0
- coder_eval/cli/console.py +7 -0
- coder_eval/cli/evaluate_command.py +164 -0
- coder_eval/cli/plan_command.py +152 -0
- coder_eval/cli/report_command.py +121 -0
- coder_eval/cli/run_command.py +686 -0
- coder_eval/cli/run_helpers.py +137 -0
- coder_eval/cli/run_task_internal_command.py +210 -0
- coder_eval/cli/utils.py +37 -0
- coder_eval/config.py +212 -0
- coder_eval/criteria/__init__.py +163 -0
- coder_eval/criteria/_classification_aggregate.py +106 -0
- coder_eval/criteria/agent_judge.py +425 -0
- coder_eval/criteria/base.py +248 -0
- coder_eval/criteria/classification_match.py +107 -0
- coder_eval/criteria/command_executed.py +181 -0
- coder_eval/criteria/commands_efficiency.py +73 -0
- coder_eval/criteria/file_check.py +119 -0
- coder_eval/criteria/file_contains.py +85 -0
- coder_eval/criteria/file_exists.py +45 -0
- coder_eval/criteria/file_matches_regex.py +86 -0
- coder_eval/criteria/json_check.py +184 -0
- coder_eval/criteria/llm_judge.py +260 -0
- coder_eval/criteria/reference_comparison.py +130 -0
- coder_eval/criteria/run_command.py +220 -0
- coder_eval/criteria/skill_triggered.py +111 -0
- coder_eval/criteria/uipath_eval.py +223 -0
- coder_eval/errors/__init__.py +26 -0
- coder_eval/errors/agent.py +26 -0
- coder_eval/errors/budget.py +28 -0
- coder_eval/errors/categories.py +205 -0
- coder_eval/errors/categorization.py +240 -0
- coder_eval/errors/executor.py +124 -0
- coder_eval/errors/judge.py +12 -0
- coder_eval/errors/retry.py +211 -0
- coder_eval/errors/timeout.py +63 -0
- coder_eval/evaluation/__init__.py +10 -0
- coder_eval/evaluation/checker.py +260 -0
- coder_eval/evaluation/judge_anthropic.py +55 -0
- coder_eval/evaluation/judge_bedrock.py +114 -0
- coder_eval/evaluation/judge_context.py +497 -0
- coder_eval/evaluation/judge_models.py +53 -0
- coder_eval/evaluation/judge_persistence.py +291 -0
- coder_eval/evaluation/judge_usage.py +50 -0
- coder_eval/evaluation/sub_agent.py +231 -0
- coder_eval/evaluation/summaries.py +48 -0
- coder_eval/evaluation/verdict_tool.py +213 -0
- coder_eval/formatting.py +191 -0
- coder_eval/isolation/__init__.py +15 -0
- coder_eval/isolation/docker_runner.py +1253 -0
- coder_eval/logging_config.py +399 -0
- coder_eval/models/__init__.py +337 -0
- coder_eval/models/agent_config.py +373 -0
- coder_eval/models/container_paths.py +26 -0
- coder_eval/models/criteria.py +942 -0
- coder_eval/models/enums.py +121 -0
- coder_eval/models/experiment.py +314 -0
- coder_eval/models/judge.py +90 -0
- coder_eval/models/judge_defaults.py +11 -0
- coder_eval/models/limits.py +89 -0
- coder_eval/models/merge_strategy.py +132 -0
- coder_eval/models/mutations.py +95 -0
- coder_eval/models/results.py +787 -0
- coder_eval/models/routing.py +160 -0
- coder_eval/models/sandbox.py +371 -0
- coder_eval/models/tasks.py +631 -0
- coder_eval/models/telemetry.py +454 -0
- coder_eval/models/templates.py +108 -0
- coder_eval/orchestration/__init__.py +13 -0
- coder_eval/orchestration/batch.py +690 -0
- coder_eval/orchestration/config.py +111 -0
- coder_eval/orchestration/config_merge.py +431 -0
- coder_eval/orchestration/evaluation.py +94 -0
- coder_eval/orchestration/experiment.py +837 -0
- coder_eval/orchestration/overrides.py +206 -0
- coder_eval/orchestration/task_loader.py +437 -0
- coder_eval/orchestrator.py +2078 -0
- coder_eval/path_utils.py +79 -0
- coder_eval/plugins.py +81 -0
- coder_eval/pricing.py +169 -0
- coder_eval/py.typed +0 -0
- coder_eval/reports.py +1066 -0
- coder_eval/reports_experiment.py +761 -0
- coder_eval/reports_html.py +1659 -0
- coder_eval/reports_stats.py +250 -0
- coder_eval/resources/__init__.py +137 -0
- coder_eval/resources/default_experiment.yaml +85 -0
- coder_eval/resources/default_ignore_patterns.yaml +60 -0
- coder_eval/resources/tags.yaml +55 -0
- coder_eval/sandbox.py +1127 -0
- coder_eval/scoring/__init__.py +11 -0
- coder_eval/scoring/ast_similarity.py +38 -0
- coder_eval/scoring/complexity.py +115 -0
- coder_eval/scoring/quality.py +154 -0
- coder_eval/scoring/signature_similarity.py +57 -0
- coder_eval/scoring/similarity.py +103 -0
- coder_eval/scoring/token_similarity.py +44 -0
- coder_eval/simulation/__init__.py +27 -0
- coder_eval/simulation/termination.py +88 -0
- coder_eval/simulation/user_simulator.py +324 -0
- coder_eval/streaming/__init__.py +44 -0
- coder_eval/streaming/callbacks.py +57 -0
- coder_eval/streaming/collector.py +193 -0
- coder_eval/streaming/events.py +198 -0
- coder_eval/streaming/renderers.py +248 -0
- coder_eval/streaming/wire.py +115 -0
- coder_eval/telemetry.py +407 -0
- coder_eval/utils.py +517 -0
- coder_eval-0.8.2.dist-info/METADATA +242 -0
- coder_eval-0.8.2.dist-info/RECORD +127 -0
- coder_eval-0.8.2.dist-info/WHEEL +4 -0
- coder_eval-0.8.2.dist-info/entry_points.txt +5 -0
- coder_eval-0.8.2.dist-info/licenses/LICENSE +201 -0
- coder_eval-0.8.2.dist-info/licenses/NOTICE +18 -0
|
@@ -0,0 +1,1253 @@
|
|
|
1
|
+
"""Run a single task inside a fresh Docker container.
|
|
2
|
+
|
|
3
|
+
Host-side counterpart of the in-container ``coder-eval _run-task-internal``
|
|
4
|
+
subcommand. Responsible for: rendering the docker-run argv, bind-mounting task
|
|
5
|
+
inputs and an output dir, streaming container stdout to the host log, and
|
|
6
|
+
reading back ``task.json`` (the only artifact that crosses the boundary).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import contextlib
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import tempfile
|
|
20
|
+
import uuid
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import TYPE_CHECKING, TextIO
|
|
24
|
+
|
|
25
|
+
import yaml
|
|
26
|
+
|
|
27
|
+
from coder_eval.logging_config import DEFAULT_LOG_TAIL_MAX_BYTES
|
|
28
|
+
from coder_eval.models import (
|
|
29
|
+
CONTAINER_INPUT_DIR,
|
|
30
|
+
CONTAINER_OUTPUT_DIR,
|
|
31
|
+
CONTAINER_WORK_DIR,
|
|
32
|
+
RESERVED_CONTAINER_DIRS,
|
|
33
|
+
AgentKind,
|
|
34
|
+
DockerDriverConfig,
|
|
35
|
+
EvaluationResult,
|
|
36
|
+
FinalStatus,
|
|
37
|
+
PreservationMode,
|
|
38
|
+
ResourceLimits,
|
|
39
|
+
)
|
|
40
|
+
from coder_eval.streaming.callbacks import safe_emit
|
|
41
|
+
from coder_eval.streaming.wire import deserialize_event, has_prefix
|
|
42
|
+
from coder_eval.utils import get_default_docker_image_tag
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from coder_eval.models import ResolvedTask
|
|
47
|
+
from coder_eval.streaming.callbacks import StreamCallback
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
logger = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Container-side paths (CONTAINER_WORK_DIR/_INPUT_DIR/_OUTPUT_DIR/_TASK_DIR,
|
|
54
|
+
# RESERVED_CONTAINER_DIRS) are imported above from models.container_paths and
|
|
55
|
+
# kept in lockstep with docker/coder_eval_entrypoint.sh.
|
|
56
|
+
|
|
57
|
+
# In-image path of the framework entrypoint, pinned by the host via
|
|
58
|
+
# `docker run --entrypoint` (the image bakes no ENTRYPOINT). MUST equal the
|
|
59
|
+
# `COPY` destination in docker/Dockerfile -- a drift guard test enforces that.
|
|
60
|
+
CONTAINER_ENTRYPOINT = "/usr/local/bin/coder_eval_entrypoint.sh"
|
|
61
|
+
|
|
62
|
+
# Top-level entries under ~/.claude that the per-task RW copy SKIPS. We copy
|
|
63
|
+
# the host's ~/.claude into a throwaway tmp dir and mount that copy read-WRITE
|
|
64
|
+
# so the in-container CLI can write anywhere it needs without ever touching the
|
|
65
|
+
# host's real ~/.claude. The container needs only auth + settings + plugins;
|
|
66
|
+
# everything else under ~/.claude is heavy, transient, or host-local state it
|
|
67
|
+
# never reads, so we drop it to keep the per-task copy cheap. On a real host
|
|
68
|
+
# this is the difference between a ~300 MB copy and a few MB: `security/` (the
|
|
69
|
+
# security plugin's data) alone is often hundreds of MB, and `projects/`
|
|
70
|
+
# (transcripts), `cache/`, `file-history/`, `backups/`, `sessions/`,
|
|
71
|
+
# `telemetry/`, `downloads/`, and `shell-snapshots/` all accumulate without
|
|
72
|
+
# bound. `session-env/` (per-Bash ephemera) is recreated fresh in the copy by
|
|
73
|
+
# the container. The last group is volatile per-session churn the *running* CLI
|
|
74
|
+
# rewrites continuously (this harness itself runs inside Claude Code, so the live
|
|
75
|
+
# host ~/.claude is mutating while we copy): dropping it both keeps the copy lean
|
|
76
|
+
# AND shrinks the window for a mid-walk vanish/rewrite race under --max-parallel
|
|
77
|
+
# (the residual race is covered by the bounded retry in `_copy_claude_home`).
|
|
78
|
+
# Patterns match by basename at every level (shutil.ignore_patterns semantics), so
|
|
79
|
+
# this is a denylist: anything NOT listed here (settings.json, .credentials.json,
|
|
80
|
+
# plugins/) is copied through.
|
|
81
|
+
CLAUDE_COPY_IGNORE = (
|
|
82
|
+
"projects",
|
|
83
|
+
"shell-snapshots",
|
|
84
|
+
"todos",
|
|
85
|
+
"session-env",
|
|
86
|
+
"security",
|
|
87
|
+
"cache",
|
|
88
|
+
"file-history",
|
|
89
|
+
"backups",
|
|
90
|
+
"downloads",
|
|
91
|
+
"sessions",
|
|
92
|
+
"telemetry",
|
|
93
|
+
"history.jsonl",
|
|
94
|
+
"*.lock",
|
|
95
|
+
# Volatile per-session churn rewritten by the live host CLI (race-prone):
|
|
96
|
+
"statsig",
|
|
97
|
+
".statusline_cache",
|
|
98
|
+
"paste-cache",
|
|
99
|
+
"tasks",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Bounded retries for the lean ~/.claude copy. The live host dir is rewritten by
|
|
103
|
+
# the running CLI while we walk it, so a file can vanish mid-copy and raise; a
|
|
104
|
+
# couple of retries clears the transient case before we give up (see
|
|
105
|
+
# `_copy_claude_home`).
|
|
106
|
+
CLAUDE_COPY_MAX_ATTEMPTS = 3
|
|
107
|
+
|
|
108
|
+
# Host-side heartbeat: the runner touches this file every HEARTBEAT_INTERVAL
|
|
109
|
+
# seconds while alive. The in-container watchdog exits if the file is stale
|
|
110
|
+
# (older than HEARTBEAT_STALE_SECONDS) -- our only defence against the host
|
|
111
|
+
# being SIGKILL'd (e.g. Claude Code's Escape) before the asyncio cleanup
|
|
112
|
+
# runs. Lives in the output dir, which is bind-mounted into the container.
|
|
113
|
+
HEARTBEAT_FILENAME = ".coder_eval_host_heartbeat"
|
|
114
|
+
HEARTBEAT_INTERVAL_SECONDS = 2.0
|
|
115
|
+
HEARTBEAT_STALE_SECONDS = 20
|
|
116
|
+
|
|
117
|
+
# asyncio's StreamReader caps a single line at 64 KiB by default. The
|
|
118
|
+
# container streams stream events as one NDJSON line each (wire.py), and a
|
|
119
|
+
# single event carrying a large tool input -- e.g. an agent Write of a whole
|
|
120
|
+
# .flow/.json file -- serialises well past 64 KiB. The default-limit reader
|
|
121
|
+
# then raises ValueError mid-stream, which tore the container down before it
|
|
122
|
+
# wrote task.json: the entire task was lost and the host recorded a bare
|
|
123
|
+
# ERROR with no per-task report. Give the line reader generous headroom (run()
|
|
124
|
+
# also degrades gracefully past it). Mirrors Orchestrator._POST_RUN_STREAM_LIMIT,
|
|
125
|
+
# the same guard on the orchestrator's post-run subprocesses.
|
|
126
|
+
STDOUT_LINE_LIMIT_BYTES = 64 * 1024 * 1024 # 64 MiB
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def _heartbeat_loop(heartbeat_path: Path) -> None:
|
|
130
|
+
"""Write a monotonic counter to ``heartbeat_path`` every interval until cancelled.
|
|
131
|
+
|
|
132
|
+
Pair with the in-container watchdog in ``run_task_internal_command``:
|
|
133
|
+
container exits when the counter stops advancing for longer than
|
|
134
|
+
``HEARTBEAT_STALE_SECONDS``. We write content (not just touch) because
|
|
135
|
+
bind-mount mtime on macOS Docker Desktop's gRPC-FUSE / VirtioFS can
|
|
136
|
+
lag by seconds; a content-encoded counter survives stalled mtime
|
|
137
|
+
semantics. Falls back gracefully if writes start failing.
|
|
138
|
+
"""
|
|
139
|
+
counter = 0
|
|
140
|
+
try:
|
|
141
|
+
while True:
|
|
142
|
+
counter += 1
|
|
143
|
+
try:
|
|
144
|
+
await asyncio.to_thread(heartbeat_path.write_text, str(counter), encoding="utf-8")
|
|
145
|
+
except OSError as exc:
|
|
146
|
+
logger.warning("Heartbeat write failed: %s", exc)
|
|
147
|
+
await asyncio.sleep(HEARTBEAT_INTERVAL_SECONDS)
|
|
148
|
+
except asyncio.CancelledError:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _preflight() -> None:
|
|
153
|
+
"""Verify ``docker`` is on PATH and the daemon is reachable.
|
|
154
|
+
|
|
155
|
+
Cheaper than letting ``docker run`` fail mid-flight: a missing binary
|
|
156
|
+
yields a clear error before we stage inputs or burn the run_dir.
|
|
157
|
+
"""
|
|
158
|
+
if shutil.which("docker") is None:
|
|
159
|
+
raise DockerRunError(
|
|
160
|
+
"docker CLI not found on PATH. Install Docker Desktop or set up Docker engine before driver: docker."
|
|
161
|
+
)
|
|
162
|
+
try:
|
|
163
|
+
subprocess.run(
|
|
164
|
+
["docker", "version", "--format", "{{.Server.Version}}"],
|
|
165
|
+
check=True,
|
|
166
|
+
capture_output=True,
|
|
167
|
+
text=True,
|
|
168
|
+
encoding="utf-8",
|
|
169
|
+
timeout=5,
|
|
170
|
+
)
|
|
171
|
+
except FileNotFoundError as exc:
|
|
172
|
+
# Race: PATH check passed but the binary disappeared before exec.
|
|
173
|
+
raise DockerRunError("docker CLI vanished between PATH check and exec.") from exc
|
|
174
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
|
|
175
|
+
raise DockerRunError("docker daemon is not responding. Start Docker Desktop or check `docker info`.") from exc
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _preflight_image_version(image: str) -> None:
|
|
179
|
+
"""Assert the image's ``coder_eval`` label matches the host BEFORE running.
|
|
180
|
+
|
|
181
|
+
The PR's original mismatch warning ran *after* ``task.json`` was parsed
|
|
182
|
+
— i.e. after the billed LLM run. The whole point of ``--driver docker``
|
|
183
|
+
is reproducibility; warning post-hoc is the wrong order. Here we inspect
|
|
184
|
+
the image label and warn *before* spawning the container, so a stale
|
|
185
|
+
``:latest`` doesn't quietly waste a paid run.
|
|
186
|
+
|
|
187
|
+
Missing image / missing label / no-host-version are all soft-fail: log
|
|
188
|
+
and continue (image may have been built before the label was added, or
|
|
189
|
+
coder-eval may be running from a source checkout without a packaged
|
|
190
|
+
version).
|
|
191
|
+
"""
|
|
192
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
host_version = version("coder-eval")
|
|
196
|
+
except PackageNotFoundError:
|
|
197
|
+
return
|
|
198
|
+
try:
|
|
199
|
+
result = subprocess.run(
|
|
200
|
+
[
|
|
201
|
+
"docker",
|
|
202
|
+
"image",
|
|
203
|
+
"inspect",
|
|
204
|
+
"--format",
|
|
205
|
+
'{{ index .Config.Labels "org.coder-eval.version" }}',
|
|
206
|
+
image,
|
|
207
|
+
],
|
|
208
|
+
check=True,
|
|
209
|
+
capture_output=True,
|
|
210
|
+
text=True,
|
|
211
|
+
encoding="utf-8",
|
|
212
|
+
timeout=10,
|
|
213
|
+
)
|
|
214
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as exc:
|
|
215
|
+
# Image absent locally or inspect failed. Let `docker run` raise the
|
|
216
|
+
# canonical error; suppress here so we don't double-fail in argv
|
|
217
|
+
# logging paths that hit this even when the image is fine.
|
|
218
|
+
logger.debug("Pre-flight image inspect failed for %s: %s", image, exc)
|
|
219
|
+
return
|
|
220
|
+
image_version = result.stdout.strip()
|
|
221
|
+
if not image_version or image_version == "unknown":
|
|
222
|
+
logger.warning(
|
|
223
|
+
"Image %s has no org.coder-eval.version label; rebuild with `make docker-image` for pre-flight checks.",
|
|
224
|
+
image,
|
|
225
|
+
)
|
|
226
|
+
return
|
|
227
|
+
if image_version != host_version:
|
|
228
|
+
logger.warning(
|
|
229
|
+
"Image %s coder_eval %s != host %s. Rebuild with `make docker-image` to keep reproducibility.",
|
|
230
|
+
image,
|
|
231
|
+
image_version,
|
|
232
|
+
host_version,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
_CONTAINER_NAME_INVALID = re.compile(r"[^a-zA-Z0-9_.-]")
|
|
237
|
+
|
|
238
|
+
# A leading Windows drive letter (``C:\foo`` / ``c:/foo``). Used so the colon
|
|
239
|
+
# in ``C:\foo`` is not misread as the ``src:dst`` separator when a Windows
|
|
240
|
+
# task author writes an extra_mounts entry. Bare ``C:`` (no path body) is
|
|
241
|
+
# intentionally not matched — that is malformed and should fail downstream.
|
|
242
|
+
_DRIVE_PREFIX = re.compile(r"^[A-Za-z]:[\\/]")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _sanitize_container_name_component(s: str) -> str:
|
|
246
|
+
"""Strip characters Docker rejects in `--name` so dataset row IDs work.
|
|
247
|
+
|
|
248
|
+
Suite/row tasks have ids like ``suite_id/row_id``; ``/`` is invalid in
|
|
249
|
+
Docker names. Anything outside ``[a-zA-Z0-9_.-]`` collapses to ``_``.
|
|
250
|
+
"""
|
|
251
|
+
return _CONTAINER_NAME_INVALID.sub("_", s)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# Destinations that would shadow framework-owned mounts inside the container.
|
|
255
|
+
# Letting a user spec collide with these silently breaks input/output staging.
|
|
256
|
+
# Same reserved set the workspace-dir validator uses (single source of truth in
|
|
257
|
+
# models.container_paths). Extra-mount destinations and WORKDIR both reject these.
|
|
258
|
+
_RESERVED_MOUNT_DESTS = RESERVED_CONTAINER_DIRS
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _validate_extra_mount(spec: str) -> str:
|
|
262
|
+
"""Sanity-check a ``-v`` mount spec and return a normalized form.
|
|
263
|
+
|
|
264
|
+
Defends against typos that would silently expose the host fs to the
|
|
265
|
+
container, and against mount specs that shadow framework-owned mounts.
|
|
266
|
+
Normalizes the source side by expanding ``~`` and ``$VAR`` so authors
|
|
267
|
+
can write portable specs. Returns the (possibly rewritten) spec to
|
|
268
|
+
feed back into argv.
|
|
269
|
+
|
|
270
|
+
Notes:
|
|
271
|
+
- Mode is REQUIRED. Forgetting ``:ro`` is the single most common way
|
|
272
|
+
to accidentally hand the container RW access to a host directory,
|
|
273
|
+
so we make the author write it explicitly.
|
|
274
|
+
- Destinations colliding with framework mounts (``/work``, ``/``,
|
|
275
|
+
etc.) are rejected outright.
|
|
276
|
+
"""
|
|
277
|
+
# Split off an optional leading Windows drive letter so the colon in
|
|
278
|
+
# ``C:\foo`` is not misread as the ``src:dst`` separator. The container
|
|
279
|
+
# side is always POSIX (Docker containers are Linux), so only the source
|
|
280
|
+
# side can carry a drive letter.
|
|
281
|
+
if _DRIVE_PREFIX.match(spec):
|
|
282
|
+
head, body = spec[:2], spec[2:]
|
|
283
|
+
else:
|
|
284
|
+
head, body = "", spec
|
|
285
|
+
parts = body.split(":")
|
|
286
|
+
if len(parts) < 2 or len(parts) > 3:
|
|
287
|
+
raise ValueError(f"Invalid extra_mounts entry {spec!r}: expected `src:dst[:ro|rw]`.")
|
|
288
|
+
src, dst = head + parts[0], parts[1]
|
|
289
|
+
# Default to read-only when mode is omitted. Mounting host paths RW
|
|
290
|
+
# by default is the wrong sandbox stance: the few RW use-cases are
|
|
291
|
+
# better stated explicitly than implied by silence.
|
|
292
|
+
mode = parts[2] if len(parts) == 3 else "ro"
|
|
293
|
+
if not src:
|
|
294
|
+
raise ValueError(f"Invalid extra_mounts entry {spec!r}: empty source path.")
|
|
295
|
+
if not dst:
|
|
296
|
+
raise ValueError(f"Invalid extra_mounts entry {spec!r}: empty destination path.")
|
|
297
|
+
if not dst.startswith("/"):
|
|
298
|
+
raise ValueError(f"Invalid extra_mounts entry {spec!r}: destination must be an absolute path.")
|
|
299
|
+
if mode not in ("ro", "rw"):
|
|
300
|
+
raise ValueError(f"Invalid extra_mounts entry {spec!r}: mode must be 'ro' or 'rw'.")
|
|
301
|
+
# Expand ~ and $VAR in the source so authors can write portable specs.
|
|
302
|
+
expanded_src = os.path.expandvars(os.path.expanduser(src))
|
|
303
|
+
if not Path(expanded_src).exists():
|
|
304
|
+
raise ValueError(f"Invalid extra_mounts entry {spec!r}: source path does not exist on host.")
|
|
305
|
+
# Reject destinations that shadow framework-owned mounts inside the
|
|
306
|
+
# container. ``/work`` substrings are caught too -- /work/foo would
|
|
307
|
+
# land underneath our staging dir and shadow the input/output tree.
|
|
308
|
+
dst_norm = dst.rstrip("/") or "/"
|
|
309
|
+
if dst_norm in _RESERVED_MOUNT_DESTS or dst_norm.startswith(CONTAINER_WORK_DIR + "/"):
|
|
310
|
+
raise ValueError(
|
|
311
|
+
f"Invalid extra_mounts entry {spec!r}: destination {dst_norm!r} shadows a framework-owned mount."
|
|
312
|
+
)
|
|
313
|
+
return f"{expanded_src}:{dst}:{mode}"
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class DockerRunError(RuntimeError):
|
|
317
|
+
"""Raised when ``docker run`` exits non-zero AND no task.json was produced.
|
|
318
|
+
|
|
319
|
+
Criterion failures do NOT raise this -- the container always writes
|
|
320
|
+
task.json (with whatever results it has) before exiting, and the host
|
|
321
|
+
parses that regardless of exit code. This is reserved for setup-time
|
|
322
|
+
failures: missing image, daemon down, OOM-kill before the agent started,
|
|
323
|
+
etc.
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class DockerBuildError(DockerRunError):
|
|
328
|
+
"""Raised when ``docker build`` itself fails (the image never builds).
|
|
329
|
+
|
|
330
|
+
A subclass of :class:`DockerRunError` so existing ``except DockerRunError``
|
|
331
|
+
handlers still catch it, but distinct so the failure is recorded as
|
|
332
|
+
:data:`FinalStatus.BUILD_FAILED` (an environment/setup failure) rather than
|
|
333
|
+
a generic ERROR. Carries the full build log so the runner can persist it to
|
|
334
|
+
``docker.log`` -- without this, a build failure happens before ``run_dir``
|
|
335
|
+
exists and the task vanishes with no log and no task.json.
|
|
336
|
+
"""
|
|
337
|
+
|
|
338
|
+
def __init__(self, message: str, *, build_log: str = "") -> None:
|
|
339
|
+
super().__init__(message)
|
|
340
|
+
self.build_log = build_log
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _assert_workspace_not_reserved(path: str) -> None:
|
|
344
|
+
"""Reject a workspace dir that collides with a framework-reserved container path.
|
|
345
|
+
|
|
346
|
+
Defense-in-depth against the same ``RESERVED_CONTAINER_DIRS`` set the
|
|
347
|
+
``SandboxConfig`` validator uses: a concrete path is already validated at the
|
|
348
|
+
model layer, but an ``"auto"``-detected image WORKDIR (or a directly built
|
|
349
|
+
argv) has not been -- so re-check here before it reaches ``docker run -w``.
|
|
350
|
+
"""
|
|
351
|
+
norm = path.rstrip("/") or "/"
|
|
352
|
+
if norm in RESERVED_CONTAINER_DIRS or norm.startswith(CONTAINER_WORK_DIR + "/"):
|
|
353
|
+
raise DockerRunError(
|
|
354
|
+
f"working_dir {path!r} collides with a framework-reserved container path (/, /work, /work/*)."
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _resolve_workspace_dir(cfg_working_dir: str | None, image: str) -> str | None:
|
|
359
|
+
"""Resolve the concrete agent workspace path (docker WORKDIR alignment).
|
|
360
|
+
|
|
361
|
+
``None`` -> ``None`` (feature off). A concrete path -> re-asserted + returned.
|
|
362
|
+
``"auto"`` -> the image's WORKDIR via ``docker image inspect`` (falling back to
|
|
363
|
+
``/root`` on an empty / ``"/"`` WORKDIR or any inspect failure -- never crash
|
|
364
|
+
the run over WORKDIR detection, mirroring ``_preflight_image_version``).
|
|
365
|
+
"""
|
|
366
|
+
if cfg_working_dir is None:
|
|
367
|
+
return None
|
|
368
|
+
if cfg_working_dir == "auto":
|
|
369
|
+
resolved = "/root"
|
|
370
|
+
try:
|
|
371
|
+
result = subprocess.run(
|
|
372
|
+
["docker", "image", "inspect", "--format", "{{.Config.WorkingDir}}", image],
|
|
373
|
+
check=True,
|
|
374
|
+
capture_output=True,
|
|
375
|
+
text=True,
|
|
376
|
+
encoding="utf-8",
|
|
377
|
+
timeout=10,
|
|
378
|
+
)
|
|
379
|
+
workdir = result.stdout.strip()
|
|
380
|
+
if workdir and workdir != "/":
|
|
381
|
+
resolved = workdir
|
|
382
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as exc:
|
|
383
|
+
logger.debug("WORKDIR inspect failed for %s; falling back to /root: %s", image, exc)
|
|
384
|
+
cfg_working_dir = resolved
|
|
385
|
+
_assert_workspace_not_reserved(cfg_working_dir)
|
|
386
|
+
return cfg_working_dir
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _copy_claude_home(host_claude_dir: Path, claude_copy: Path) -> None:
|
|
390
|
+
"""Copy the host ``~/.claude`` into ``claude_copy`` with bounded retries.
|
|
391
|
+
|
|
392
|
+
The harness itself runs inside Claude Code, so the *live* host ``~/.claude``
|
|
393
|
+
is actively rewritten (small state JSON, session ephemera) while this walks
|
|
394
|
+
it. Under ``--max-parallel>1`` N tasks copy it concurrently, and a file that
|
|
395
|
+
vanishes or is rewritten mid-walk makes ``shutil.copytree`` raise
|
|
396
|
+
``FileNotFoundError`` / ``shutil.Error`` (both ``OSError`` subclasses). Left
|
|
397
|
+
uncaught that propagates to ``run_single``'s broad ``except`` and flips an
|
|
398
|
+
otherwise-passing task to ``FinalStatus.ERROR`` — scoring identical agent
|
|
399
|
+
output differently by luck of timing. ``CLAUDE_COPY_IGNORE`` already drops the
|
|
400
|
+
noisiest churn dirs; this retries the residual race a bounded number of times
|
|
401
|
+
(clearing the partial copy between attempts) before giving up. Persistent
|
|
402
|
+
failure still raises — at that point it is a real problem (e.g. perms), and
|
|
403
|
+
the container could not authenticate without ``~/.claude`` anyway.
|
|
404
|
+
"""
|
|
405
|
+
last_exc: OSError | None = None
|
|
406
|
+
for attempt in range(1, CLAUDE_COPY_MAX_ATTEMPTS + 1):
|
|
407
|
+
try:
|
|
408
|
+
shutil.copytree(
|
|
409
|
+
host_claude_dir,
|
|
410
|
+
claude_copy,
|
|
411
|
+
ignore=shutil.ignore_patterns(*CLAUDE_COPY_IGNORE),
|
|
412
|
+
# Copy symlinks AS symlinks (do not follow): a plugin marketplace
|
|
413
|
+
# cache can contain a self-referential symlink (e.g. uipath-marketplace
|
|
414
|
+
# `plugins/uipath -> ..`) that makes a symlink-following walk recurse
|
|
415
|
+
# infinitely ("too many levels of symbolic links") and abort the copy.
|
|
416
|
+
# Copying them verbatim is correct and loop-proof. Dangling ones are
|
|
417
|
+
# skipped via ignore_dangling_symlinks.
|
|
418
|
+
symlinks=True,
|
|
419
|
+
ignore_dangling_symlinks=True,
|
|
420
|
+
dirs_exist_ok=True,
|
|
421
|
+
)
|
|
422
|
+
return
|
|
423
|
+
except OSError as exc: # FileNotFoundError / shutil.Error — transient under concurrent host churn
|
|
424
|
+
last_exc = exc
|
|
425
|
+
# Clear the partial tree so the retry (dirs_exist_ok) starts clean.
|
|
426
|
+
shutil.rmtree(claude_copy, ignore_errors=True)
|
|
427
|
+
logger.warning(
|
|
428
|
+
"Copy of host ~/.claude failed (attempt %d/%d), retrying: %s",
|
|
429
|
+
attempt,
|
|
430
|
+
CLAUDE_COPY_MAX_ATTEMPTS,
|
|
431
|
+
exc,
|
|
432
|
+
)
|
|
433
|
+
raise DockerRunError(
|
|
434
|
+
f"Failed to copy host ~/.claude into the container staging dir after {CLAUDE_COPY_MAX_ATTEMPTS} "
|
|
435
|
+
+ f"attempts (last error: {last_exc}). The host dir may be churning faster than the copy "
|
|
436
|
+
+ "completes, or be unreadable."
|
|
437
|
+
) from last_exc
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class DockerRunner:
|
|
441
|
+
"""Spawns a per-task container and reconstructs the EvaluationResult.
|
|
442
|
+
|
|
443
|
+
One instance per task. Stateless across tasks -- batch execution just
|
|
444
|
+
instantiates N runners concurrently.
|
|
445
|
+
"""
|
|
446
|
+
|
|
447
|
+
def __init__(
|
|
448
|
+
self,
|
|
449
|
+
rt: ResolvedTask,
|
|
450
|
+
preservation_mode: PreservationMode = PreservationMode.DIRECT_WRITE,
|
|
451
|
+
stream_callback: StreamCallback | None = None,
|
|
452
|
+
verbose: bool = False,
|
|
453
|
+
) -> None:
|
|
454
|
+
self.rt = rt
|
|
455
|
+
self.preservation_mode = preservation_mode
|
|
456
|
+
self.stream_callback = stream_callback
|
|
457
|
+
self.verbose = verbose
|
|
458
|
+
# Set by _prepare_host_mounts: the tmp lean copy of ~/.claude that
|
|
459
|
+
# _build_argv mounts read-write. None when there is no ~/.claude to
|
|
460
|
+
# forward or the mount is opted out (CODER_EVAL_NO_CLAUDE_MOUNT).
|
|
461
|
+
self._claude_mount_src: Path | None = None
|
|
462
|
+
# Resolved in run() (needs the built image for "auto"). Concrete WORKDIR the
|
|
463
|
+
# agent runs at + copies out from; None = standard artifacts workspace.
|
|
464
|
+
self._workspace_dir: str | None = None
|
|
465
|
+
|
|
466
|
+
@property
|
|
467
|
+
def _docker_config(self) -> DockerDriverConfig:
|
|
468
|
+
return self.rt.task.sandbox.docker
|
|
469
|
+
|
|
470
|
+
@property
|
|
471
|
+
def _limits(self) -> ResourceLimits:
|
|
472
|
+
return self.rt.task.sandbox.limits
|
|
473
|
+
|
|
474
|
+
async def run(self) -> EvaluationResult:
|
|
475
|
+
"""Run the task in a container and return the parsed EvaluationResult.
|
|
476
|
+
|
|
477
|
+
The container is responsible for producing ``task.json`` in
|
|
478
|
+
``CONTAINER_OUTPUT_DIR``. On any path where the container exits
|
|
479
|
+
without producing it, this raises ``DockerRunError`` and the batch
|
|
480
|
+
dispatcher converts that to an ERROR-status EvaluationResult.
|
|
481
|
+
"""
|
|
482
|
+
_preflight()
|
|
483
|
+
# Resolve the run image: build from a Dockerfile if configured (which
|
|
484
|
+
# overrides `image`), else use the configured image. The build is
|
|
485
|
+
# side-effecting, so it runs in a worker thread like the other docker
|
|
486
|
+
# calls in this method.
|
|
487
|
+
try:
|
|
488
|
+
image = await asyncio.to_thread(self._build_image)
|
|
489
|
+
except DockerBuildError as exc:
|
|
490
|
+
# The build happens before run_dir/docker.log/task.json exist, so a
|
|
491
|
+
# build failure would otherwise leave an empty result dir with no
|
|
492
|
+
# trace. Persist the build log to docker.log and a BUILD_FAILED
|
|
493
|
+
# synthetic task.json so the failure is visible per-task, then
|
|
494
|
+
# re-raise for the batch dispatcher to record run-level.
|
|
495
|
+
await self._record_build_failure(exc)
|
|
496
|
+
raise
|
|
497
|
+
# The version-label preflight only makes sense for the framework image;
|
|
498
|
+
# a task-supplied Dockerfile won't carry the org.coder-eval.version label.
|
|
499
|
+
if not self._docker_config.dockerfile_path:
|
|
500
|
+
await asyncio.to_thread(_preflight_image_version, image)
|
|
501
|
+
await asyncio.to_thread(self.rt.run_dir.mkdir, parents=True, exist_ok=True)
|
|
502
|
+
|
|
503
|
+
# Docker WORKDIR alignment: resolve the concrete workspace path
|
|
504
|
+
# once, host-side (config value / "auto" -> inspect the built image / fallback
|
|
505
|
+
# /root). Forwarded to the in-container orchestrator via the staged context
|
|
506
|
+
# and rendered as `docker run -w`. None keeps the standard artifacts workspace.
|
|
507
|
+
self._workspace_dir = await asyncio.to_thread(_resolve_workspace_dir, self._docker_config.working_dir, image)
|
|
508
|
+
|
|
509
|
+
# Stage only the inputs (task YAML + context). The *output* dir is
|
|
510
|
+
# the host's run_dir itself, bind-mounted at the same path inside
|
|
511
|
+
# the container so the in-container Orchestrator writes
|
|
512
|
+
# task.json/task.log/task.html/artifacts/ straight into the host
|
|
513
|
+
# filesystem -- no copy step, paths are symmetric inside and out.
|
|
514
|
+
# Sanitize task_id: dataset ids are ``suite_id/row_id`` and the ``/`` breaks mkdtemp (missing parent dir).
|
|
515
|
+
safe_staging_id = _sanitize_container_name_component(self.rt.task.task_id)
|
|
516
|
+
staging = Path(await asyncio.to_thread(tempfile.mkdtemp, prefix=f"coder_eval_docker_{safe_staging_id}_"))
|
|
517
|
+
input_dir = staging / "input"
|
|
518
|
+
await asyncio.to_thread(input_dir.mkdir)
|
|
519
|
+
output_dir = self.rt.run_dir.resolve()
|
|
520
|
+
|
|
521
|
+
try:
|
|
522
|
+
await self._stage_inputs(input_dir)
|
|
523
|
+
|
|
524
|
+
# Give the container a stable, *unique* name so cancellation can
|
|
525
|
+
# target it. PID alone collides under --max-parallel >1 (same
|
|
526
|
+
# host process spawns N concurrent containers); the uuid suffix
|
|
527
|
+
# and replicate_index disambiguate. Sanitize+truncate task_id
|
|
528
|
+
# so dataset row ids like ``suite/row`` don't break docker name
|
|
529
|
+
# validation.
|
|
530
|
+
short_uuid = uuid.uuid4().hex[:8]
|
|
531
|
+
# Docker name limit is 253 chars; keep generous task_id headroom
|
|
532
|
+
# so `docker ps` rows stay readable. Earlier 30-char cap collided
|
|
533
|
+
# visibly on long shared prefixes; 80 covers all realistic ids
|
|
534
|
+
# while leaving room for the suffix.
|
|
535
|
+
safe_task_id = _sanitize_container_name_component(self.rt.task.task_id)[:80]
|
|
536
|
+
container_name = f"coder-eval-{safe_task_id}-r{self.rt.replicate_index}-{os.getpid()}-{short_uuid}"
|
|
537
|
+
# Side-effecting prep that _build_argv must NOT do (argv rendering
|
|
538
|
+
# stays pure for testability). Makes a lean RW copy of ~/.claude
|
|
539
|
+
# under `staging` and records it on self._claude_mount_src for
|
|
540
|
+
# _build_argv to mount. Cleaned up with `staging` in the finally.
|
|
541
|
+
await asyncio.to_thread(self._prepare_host_mounts, staging)
|
|
542
|
+
argv = self._build_argv(input_dir, output_dir, container_name=container_name, image=image)
|
|
543
|
+
logger.info("Running task '%s' in docker: %s", self.rt.task.task_id, " ".join(argv))
|
|
544
|
+
# Prime the heartbeat before the container starts so the
|
|
545
|
+
# watchdog never sees an initial stale state.
|
|
546
|
+
heartbeat_path = output_dir / HEARTBEAT_FILENAME
|
|
547
|
+
await asyncio.to_thread(heartbeat_path.touch)
|
|
548
|
+
heartbeat_task = asyncio.create_task(_heartbeat_loop(heartbeat_path))
|
|
549
|
+
proc = await asyncio.create_subprocess_exec(
|
|
550
|
+
*argv,
|
|
551
|
+
stdout=asyncio.subprocess.PIPE,
|
|
552
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
553
|
+
limit=STDOUT_LINE_LIMIT_BYTES,
|
|
554
|
+
)
|
|
555
|
+
log_path = self.rt.run_dir / "docker.log"
|
|
556
|
+
log_fh = await asyncio.to_thread(log_path.open, "w", encoding="utf-8")
|
|
557
|
+
# Cancellation guard: `docker run --rm` does NOT propagate kill
|
|
558
|
+
# to the container daemon-side. Without this `finally`, Ctrl-C
|
|
559
|
+
# on the host leaves the container running and burning LLM
|
|
560
|
+
# budget. Covers CancelledError, KeyboardInterrupt, and any
|
|
561
|
+
# other exit-by-exception path uniformly.
|
|
562
|
+
try:
|
|
563
|
+
returncode = await self._stream_container_output(proc, log_fh)
|
|
564
|
+
finally:
|
|
565
|
+
heartbeat_task.cancel()
|
|
566
|
+
# await the cancellation so the task doesn't outlive us;
|
|
567
|
+
# narrow to CancelledError so genuine KeyboardInterrupt /
|
|
568
|
+
# SystemExit from a parallel sibling still propagates.
|
|
569
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
570
|
+
await heartbeat_task
|
|
571
|
+
await asyncio.to_thread(log_fh.close)
|
|
572
|
+
# If proc is still alive we got cancelled mid-flight. Kill
|
|
573
|
+
# the container *and* the docker CLI subprocess. Best-effort,
|
|
574
|
+
# no exception leak from cleanup.
|
|
575
|
+
if proc.returncode is None:
|
|
576
|
+
await self._kill_container(proc, container_name)
|
|
577
|
+
|
|
578
|
+
return await self._parse_result_or_raise(output_dir, returncode, log_path)
|
|
579
|
+
finally:
|
|
580
|
+
await asyncio.to_thread(shutil.rmtree, staging, ignore_errors=True)
|
|
581
|
+
|
|
582
|
+
async def _stage_inputs(self, input_dir: Path) -> None:
|
|
583
|
+
"""Serialise the post-override TaskDefinition + lineage/variant context into the
|
|
584
|
+
staging ``input_dir`` (``task.yaml`` + ``context.json``). Pure I/O off the event
|
|
585
|
+
loop; no control-flow change.
|
|
586
|
+
"""
|
|
587
|
+
# Always serialise the *post-override* TaskDefinition. We can't use
|
|
588
|
+
# rt.source_yaml because that's the raw on-disk text -- _apply_cli_overrides
|
|
589
|
+
# has since mutated rt.task in-memory (e.g. --model, -D run_limits.max_turns), and the
|
|
590
|
+
# container needs to see those mutations.
|
|
591
|
+
task_yaml_in = input_dir / "task.yaml"
|
|
592
|
+
|
|
593
|
+
def _dump_task_yaml() -> str:
|
|
594
|
+
return yaml.safe_dump(self.rt.task.model_dump(mode="json"), sort_keys=False)
|
|
595
|
+
|
|
596
|
+
task_yaml_text = await asyncio.to_thread(_dump_task_yaml)
|
|
597
|
+
await asyncio.to_thread(task_yaml_in.write_text, task_yaml_text, encoding="utf-8")
|
|
598
|
+
# Lineage + variant metadata so the in-container Orchestrator
|
|
599
|
+
# reconstructs the same context (variant_id is load-bearing for
|
|
600
|
+
# report grouping). source_yaml carries the *raw* on-disk text
|
|
601
|
+
# so the in-container Orchestrator records the same audit trail
|
|
602
|
+
# as the in-process driver (task.json.task_config.source_yaml).
|
|
603
|
+
context_payload = json.dumps(
|
|
604
|
+
{
|
|
605
|
+
"variant_id": self.rt.variant_id,
|
|
606
|
+
"replicate_index": self.rt.replicate_index,
|
|
607
|
+
"config_lineage": {k: v.model_dump(mode="json") for k, v in self.rt.config_lineage.items()},
|
|
608
|
+
"preservation_mode": self.preservation_mode.value,
|
|
609
|
+
"source_yaml": self.rt.source_yaml,
|
|
610
|
+
# Docker WORKDIR alignment: concrete path the in-container
|
|
611
|
+
# orchestrator runs at + captures out (None = standard workspace).
|
|
612
|
+
"workspace_dir": self._workspace_dir,
|
|
613
|
+
}
|
|
614
|
+
)
|
|
615
|
+
await asyncio.to_thread((input_dir / "context.json").write_text, context_payload, encoding="utf-8")
|
|
616
|
+
|
|
617
|
+
async def _stream_container_output(self, proc: asyncio.subprocess.Process, log_fh: TextIO) -> int:
|
|
618
|
+
"""Stream the container's stdout, returning its exit code.
|
|
619
|
+
|
|
620
|
+
Wire-format lines emit to the host ``StreamCallback``; plain lines are written
|
|
621
|
+
to ``docker.log``. A single over-limit line is dropped (degrade, not die) — the
|
|
622
|
+
``readline`` ``ValueError`` resyncs at the next newline. Runs as the inner-``try``
|
|
623
|
+
body of ``run``; the caller owns the ``finally`` cleanup, so this helper never
|
|
624
|
+
touches the heartbeat/log-fh/container teardown.
|
|
625
|
+
"""
|
|
626
|
+
assert proc.stdout is not None
|
|
627
|
+
# Explicit readline loop (not `async for`) so a single
|
|
628
|
+
# over-limit line degrades to a dropped line instead of a
|
|
629
|
+
# ValueError that tears the whole task down -- see below.
|
|
630
|
+
while True:
|
|
631
|
+
try:
|
|
632
|
+
raw_line = await proc.stdout.readline()
|
|
633
|
+
except ValueError:
|
|
634
|
+
# A single line exceeded STDOUT_LINE_LIMIT_BYTES.
|
|
635
|
+
# readline() drains the offending bytes and resyncs at
|
|
636
|
+
# the next newline, so we keep streaming. The dropped
|
|
637
|
+
# line is a STREAM_EVENT (host-side live render) or a
|
|
638
|
+
# log line; task.json crosses via the bind mount, not
|
|
639
|
+
# stdout, so the task result is unaffected. Degrade,
|
|
640
|
+
# don't die.
|
|
641
|
+
logger.warning(
|
|
642
|
+
"Dropped a stdout line over %d bytes from task %r's container; continuing to stream.",
|
|
643
|
+
STDOUT_LINE_LIMIT_BYTES,
|
|
644
|
+
self.rt.task.task_id,
|
|
645
|
+
)
|
|
646
|
+
continue
|
|
647
|
+
if not raw_line:
|
|
648
|
+
break
|
|
649
|
+
line = raw_line.decode("utf-8", errors="replace").rstrip("\n")
|
|
650
|
+
# Three-way split:
|
|
651
|
+
# - Has the wire-format prefix AND parses cleanly -> emit
|
|
652
|
+
# to the host StreamCallback; do not echo to docker.log
|
|
653
|
+
# (the StreamCallback is the canonical destination).
|
|
654
|
+
# - Has the prefix but parses badly -> wire bug;
|
|
655
|
+
# deserialize_event already logged a WARN. Preserve
|
|
656
|
+
# the raw line in docker.log so it isn't lost.
|
|
657
|
+
# - No prefix -> plain log line.
|
|
658
|
+
if has_prefix(line):
|
|
659
|
+
event = deserialize_event(line)
|
|
660
|
+
if event is not None:
|
|
661
|
+
safe_emit(self.stream_callback, event)
|
|
662
|
+
continue
|
|
663
|
+
# fall through to log preservation
|
|
664
|
+
log_fn = logger.info if self.verbose else logger.debug
|
|
665
|
+
log_fn("[docker:%s] %s", self.rt.task.task_id, line)
|
|
666
|
+
await asyncio.to_thread(log_fh.write, line + "\n")
|
|
667
|
+
await asyncio.to_thread(log_fh.flush)
|
|
668
|
+
return await proc.wait()
|
|
669
|
+
|
|
670
|
+
async def _kill_container(self, proc: asyncio.subprocess.Process, container_name: str) -> None:
|
|
671
|
+
"""Best-effort teardown when cancelled mid-stream with the container still alive.
|
|
672
|
+
|
|
673
|
+
Called from ``run``'s inner ``finally`` (after heartbeat-cancel + log-fh close),
|
|
674
|
+
guarded by ``if proc.returncode is None``. ``docker run --rm`` does NOT propagate
|
|
675
|
+
a host-side kill to the daemon, so kill the container by name and then the docker
|
|
676
|
+
CLI subprocess. No exception leaks from cleanup; suppression is narrowed to
|
|
677
|
+
CancelledError so KeyboardInterrupt / SystemExit from parallel siblings propagate.
|
|
678
|
+
"""
|
|
679
|
+
logger.warning("Cleanup: killing container %s", container_name)
|
|
680
|
+
try:
|
|
681
|
+
kill_result = await asyncio.to_thread(
|
|
682
|
+
subprocess.run,
|
|
683
|
+
["docker", "kill", container_name],
|
|
684
|
+
capture_output=True,
|
|
685
|
+
check=False,
|
|
686
|
+
timeout=10,
|
|
687
|
+
)
|
|
688
|
+
if kill_result.returncode == 0:
|
|
689
|
+
logger.info("Container %s killed cleanly.", container_name)
|
|
690
|
+
else:
|
|
691
|
+
# Non-zero from `docker kill` typically means the
|
|
692
|
+
# container was already gone (race with --rm) OR
|
|
693
|
+
# the daemon refused. Surface stderr so the
|
|
694
|
+
# ambiguity is debuggable.
|
|
695
|
+
logger.warning(
|
|
696
|
+
"docker kill %s returned %s; container may already be gone or daemon refused: %s",
|
|
697
|
+
container_name,
|
|
698
|
+
kill_result.returncode,
|
|
699
|
+
kill_result.stderr.decode("utf-8", errors="replace").strip(),
|
|
700
|
+
)
|
|
701
|
+
except subprocess.TimeoutExpired:
|
|
702
|
+
# Daemon hung; container may now be orphaned daemon-side.
|
|
703
|
+
# Loud so an operator notices and prunes manually.
|
|
704
|
+
logger.error(
|
|
705
|
+
"docker kill %s timed out after 10s; container may be orphaned. Investigate `docker ps`.",
|
|
706
|
+
container_name,
|
|
707
|
+
)
|
|
708
|
+
except (OSError, subprocess.SubprocessError) as kill_exc:
|
|
709
|
+
logger.warning("docker kill failed: %s", kill_exc)
|
|
710
|
+
with contextlib.suppress(ProcessLookupError):
|
|
711
|
+
proc.kill()
|
|
712
|
+
# Narrow to CancelledError -- a generic BaseException
|
|
713
|
+
# catch here would silently eat KeyboardInterrupt /
|
|
714
|
+
# SystemExit propagation from parallel tasks.
|
|
715
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
716
|
+
await proc.wait()
|
|
717
|
+
|
|
718
|
+
async def _parse_result_or_raise(self, output_dir: Path, returncode: int, log_path: Path) -> EvaluationResult:
|
|
719
|
+
"""Read back ``task.json`` (the only artifact crossing the boundary) and parse it.
|
|
720
|
+
|
|
721
|
+
If the container exited without producing it, persist a synthetic ERROR
|
|
722
|
+
task.json and raise ``DockerRunError`` so the batch dispatcher records the
|
|
723
|
+
failure as an ERROR-status result.
|
|
724
|
+
"""
|
|
725
|
+
task_json = output_dir / "task.json"
|
|
726
|
+
if not await asyncio.to_thread(task_json.exists):
|
|
727
|
+
# The container died before its orchestrator's `finally` could
|
|
728
|
+
# write task.json (e.g. it was torn down by the cleanup above
|
|
729
|
+
# after a host-side stream failure, or killed externally).
|
|
730
|
+
# Persist a synthetic ERROR task.json so the test stays
|
|
731
|
+
# visible on dashboards/timelines instead of silently
|
|
732
|
+
# vanishing -- the batch layer's in-memory skeleton never
|
|
733
|
+
# reaches the per-task dir.
|
|
734
|
+
error = DockerRunError(
|
|
735
|
+
f"Container exited with code {returncode} without producing task.json. "
|
|
736
|
+
+ f"See {log_path} for container output."
|
|
737
|
+
)
|
|
738
|
+
await self._write_synthetic_task_json(task_json, error)
|
|
739
|
+
raise error
|
|
740
|
+
|
|
741
|
+
# output_dir IS rt.run_dir -- no copy needed.
|
|
742
|
+
task_json_text = await asyncio.to_thread(task_json.read_text, encoding="utf-8")
|
|
743
|
+
try:
|
|
744
|
+
result = EvaluationResult.model_validate_json(task_json_text)
|
|
745
|
+
except ValueError as exc:
|
|
746
|
+
# Present but unparseable (schema skew from a stale image, or a
|
|
747
|
+
# truncated/torn write). Degrade like the missing-file branch
|
|
748
|
+
# rather than crashing with an uncaught ValidationError/JSONDecodeError.
|
|
749
|
+
raise await self._handle_malformed_task_json(task_json, log_path, exc) from exc
|
|
750
|
+
self._warn_on_version_mismatch(result)
|
|
751
|
+
return result
|
|
752
|
+
|
|
753
|
+
async def _handle_malformed_task_json(self, task_json: Path, log_path: Path, exc: ValueError) -> DockerRunError:
|
|
754
|
+
"""Degrade a present-but-malformed task.json; return the DockerRunError to raise.
|
|
755
|
+
|
|
756
|
+
Triggered by a present-but-unparseable task.json -- most realistically a
|
|
757
|
+
schema skew between a stale ``:latest`` image and the host (the version
|
|
758
|
+
checks only warn), or a truncated/torn write. Mirrors the missing-file
|
|
759
|
+
branch and the batch.py recovery paths: log naming the path, move the
|
|
760
|
+
original aside to ``task.json.malformed`` (so its possibly-recoverable
|
|
761
|
+
content isn't masked AND so the synthetic write lands --
|
|
762
|
+
``_write_synthetic_task_json`` never overwrites an existing file),
|
|
763
|
+
persist a synthetic ERROR record (per-task dashboard visibility), and
|
|
764
|
+
return the error for the caller to raise (the batch layer records the
|
|
765
|
+
run-level ERROR). Best-effort throughout: a failed move is logged, never
|
|
766
|
+
masking the raise.
|
|
767
|
+
"""
|
|
768
|
+
logger.warning("Malformed task.json at %s: %s", task_json, exc)
|
|
769
|
+
sidecar = task_json.with_suffix(task_json.suffix + ".malformed")
|
|
770
|
+
|
|
771
|
+
def _move() -> None:
|
|
772
|
+
os.replace(task_json, sidecar) # atomic; overwrites any stale prior .malformed
|
|
773
|
+
|
|
774
|
+
try:
|
|
775
|
+
await asyncio.to_thread(_move)
|
|
776
|
+
except OSError as move_exc:
|
|
777
|
+
logger.warning("Failed to preserve malformed task.json %s: %s", task_json, move_exc)
|
|
778
|
+
|
|
779
|
+
error = DockerRunError(f"task.json at {task_json} is malformed. See {log_path} for container output.")
|
|
780
|
+
await self._write_synthetic_task_json(task_json, error)
|
|
781
|
+
return error
|
|
782
|
+
|
|
783
|
+
async def _record_build_failure(self, exc: DockerBuildError) -> None:
|
|
784
|
+
"""Persist a failed image build so it is visible, not a silent empty dir.
|
|
785
|
+
|
|
786
|
+
``_build_image`` runs before ``run_dir``, ``docker.log``, or ``task.json``
|
|
787
|
+
exist, so a build failure used to leave an empty result directory with no
|
|
788
|
+
status and no log. This creates ``run_dir``, writes the captured build log
|
|
789
|
+
to ``docker.log`` (where every per-task consumer already looks for
|
|
790
|
+
container output), and writes a synthetic ``BUILD_FAILED`` task.json.
|
|
791
|
+
Best-effort: any IO failure here is logged and never masks the
|
|
792
|
+
``DockerBuildError`` the caller re-raises.
|
|
793
|
+
"""
|
|
794
|
+
try:
|
|
795
|
+
await asyncio.to_thread(self.rt.run_dir.mkdir, parents=True, exist_ok=True)
|
|
796
|
+
log_path = self.rt.run_dir / "docker.log"
|
|
797
|
+
await asyncio.to_thread(log_path.write_text, exc.build_log or str(exc), encoding="utf-8")
|
|
798
|
+
await self._write_synthetic_task_json(self.rt.run_dir / "task.json", exc, status=FinalStatus.BUILD_FAILED)
|
|
799
|
+
except OSError as io_exc: # pragma: no cover - defensive
|
|
800
|
+
logger.warning("Failed to record build failure for %s: %s", self.rt.task.task_id, io_exc)
|
|
801
|
+
|
|
802
|
+
async def _write_synthetic_task_json(
|
|
803
|
+
self, target: Path, error: DockerRunError, *, status: FinalStatus = FinalStatus.ERROR
|
|
804
|
+
) -> None:
|
|
805
|
+
"""Persist a minimal error task.json for a container that died pre-write.
|
|
806
|
+
|
|
807
|
+
A container killed mid-task (SIGKILL, or torn down by our own
|
|
808
|
+
cancellation cleanup) never reaches the in-container `finally` that
|
|
809
|
+
writes task.json, so without this the task is recorded only in the
|
|
810
|
+
batch layer's in-memory error skeleton and vanishes from every
|
|
811
|
+
per-task consumer (dashboard, timelines). Reuses
|
|
812
|
+
:func:`build_error_result` -- the documented mirror of
|
|
813
|
+
``_create_error_task_result`` -- and the Orchestrator's own
|
|
814
|
+
``model_dump_json(indent=2)`` serialization so downstream readers
|
|
815
|
+
parse it unchanged.
|
|
816
|
+
|
|
817
|
+
Atomic (tmp + os.replace), never overwrites an existing task.json
|
|
818
|
+
(if the container won the race after all, the real result wins), and
|
|
819
|
+
best-effort: a write failure logs a warning and never masks the
|
|
820
|
+
DockerRunError the caller is about to raise.
|
|
821
|
+
"""
|
|
822
|
+
result = build_error_result(self.rt, error, status=status)
|
|
823
|
+
|
|
824
|
+
def _write() -> None:
|
|
825
|
+
if target.exists():
|
|
826
|
+
return
|
|
827
|
+
tmp = target.with_suffix(target.suffix + ".synthetic.tmp")
|
|
828
|
+
tmp.write_text(result.model_dump_json(indent=2), encoding="utf-8")
|
|
829
|
+
os.replace(tmp, target)
|
|
830
|
+
|
|
831
|
+
try:
|
|
832
|
+
await asyncio.to_thread(_write)
|
|
833
|
+
except OSError as exc:
|
|
834
|
+
logger.warning("Failed to write synthetic task.json to %s: %s", target, exc)
|
|
835
|
+
|
|
836
|
+
def _warn_on_version_mismatch(self, result: EvaluationResult) -> None:
|
|
837
|
+
"""Warn loudly if the in-container coder_eval version != the host's.
|
|
838
|
+
|
|
839
|
+
Reproducibility is one of two reasons users pick driver:docker.
|
|
840
|
+
Without this check, an outdated image silently runs stale code
|
|
841
|
+
against a refreshed host -- a class of "works on my machine"
|
|
842
|
+
regression that's near-impossible to debug. The host already
|
|
843
|
+
embeds its own version in environment_info before this point.
|
|
844
|
+
"""
|
|
845
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
846
|
+
|
|
847
|
+
try:
|
|
848
|
+
host_version = version("coder-eval")
|
|
849
|
+
except PackageNotFoundError:
|
|
850
|
+
return
|
|
851
|
+
env_info = result.environment_info or {}
|
|
852
|
+
if "coder_eval" not in env_info:
|
|
853
|
+
# Surface the silent-disable. Future refactor removing this key
|
|
854
|
+
# would otherwise stop the version check without anyone noticing.
|
|
855
|
+
logger.warning(
|
|
856
|
+
"Cannot verify container coder_eval version: result.environment_info missing 'coder_eval' key."
|
|
857
|
+
)
|
|
858
|
+
return
|
|
859
|
+
container_version = env_info["coder_eval"]
|
|
860
|
+
if container_version and container_version != host_version:
|
|
861
|
+
logger.warning(
|
|
862
|
+
"coder_eval version mismatch -- host %s, container %s. Rebuild image with `make docker-image`.",
|
|
863
|
+
host_version,
|
|
864
|
+
container_version,
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
@staticmethod
|
|
868
|
+
def _sensitive_source_paths() -> list[Path]:
|
|
869
|
+
"""Host paths whose auto-mount should emit a loud warning.
|
|
870
|
+
|
|
871
|
+
Not a hard denylist: there are legitimate task shapes that need to
|
|
872
|
+
read e.g. ``~/.aws`` (cloud-deploy validators). Warning gives the
|
|
873
|
+
author visibility without breaking those tasks.
|
|
874
|
+
"""
|
|
875
|
+
home = Path.home()
|
|
876
|
+
candidates = [
|
|
877
|
+
home / ".ssh",
|
|
878
|
+
home / ".aws",
|
|
879
|
+
home / ".gnupg",
|
|
880
|
+
home / ".config" / "gh",
|
|
881
|
+
home / ".kube",
|
|
882
|
+
Path("/etc"),
|
|
883
|
+
]
|
|
884
|
+
return [p.resolve() for p in candidates if p.exists()]
|
|
885
|
+
|
|
886
|
+
def _prepare_host_mounts(self, staging: Path) -> None:
|
|
887
|
+
"""Side-effecting prep that ``_build_argv`` must not do.
|
|
888
|
+
|
|
889
|
+
Makes a *lean copy* of the host's ``~/.claude`` into a throwaway dir
|
|
890
|
+
under ``staging`` and records it on ``self._claude_mount_src``.
|
|
891
|
+
``_build_argv`` then bind-mounts that copy read-WRITE at the host's
|
|
892
|
+
``~/.claude`` path (HOME is forwarded, so the path is symmetric inside
|
|
893
|
+
the container). Mounting a copy — rather than the host dir read-only —
|
|
894
|
+
lets the in-container CLI write anywhere under ``~/.claude`` without
|
|
895
|
+
ever mutating the host's real state.
|
|
896
|
+
|
|
897
|
+
The copy skips heavy, container-irrelevant per-session state
|
|
898
|
+
(``CLAUDE_COPY_IGNORE``) so it stays cheap even in parallel batches.
|
|
899
|
+
|
|
900
|
+
The copy lives under ``staging``, which ``run()`` removes in its
|
|
901
|
+
``finally``, so there is no extra cleanup to track. Argv rendering must
|
|
902
|
+
stay pure (it may run twice — for logging then exec), so the copy is
|
|
903
|
+
made here, exactly once, rather than in ``_build_argv``.
|
|
904
|
+
"""
|
|
905
|
+
if os.environ.get("CODER_EVAL_NO_CLAUDE_MOUNT"):
|
|
906
|
+
return
|
|
907
|
+
host_claude_dir = Path.home() / ".claude"
|
|
908
|
+
if not host_claude_dir.is_dir():
|
|
909
|
+
return
|
|
910
|
+
claude_copy = staging / "claude-home"
|
|
911
|
+
_copy_claude_home(host_claude_dir, claude_copy)
|
|
912
|
+
self._claude_mount_src = claude_copy
|
|
913
|
+
|
|
914
|
+
def _build_image(self) -> str:
|
|
915
|
+
"""Resolve the image to run, building from a Dockerfile when configured.
|
|
916
|
+
|
|
917
|
+
When ``docker.dockerfile_path`` is set it overrides ``docker.image``:
|
|
918
|
+
we shell out to ``docker build`` using the Dockerfile's parent directory
|
|
919
|
+
as the build context (so relative ``COPY`` paths resolve) and tag the
|
|
920
|
+
result with a deterministic, per-task name so Docker's layer cache is
|
|
921
|
+
reused across runs. ``docker.build`` (:class:`DockerBuildConfig`) adds
|
|
922
|
+
``--build-arg`` / ``--secret`` / extra flags; the build runs with
|
|
923
|
+
BuildKit enabled. Otherwise the configured ``image`` is returned
|
|
924
|
+
unchanged.
|
|
925
|
+
|
|
926
|
+
**Contract:** the container runs the coder-eval orchestrator. The image
|
|
927
|
+
bakes no ``ENTRYPOINT``; the host pins it at run time via
|
|
928
|
+
``docker run --entrypoint`` (see :meth:`_build_argv`). A task Dockerfile
|
|
929
|
+
must therefore start ``FROM coder-eval-agent:<version>`` and only ADD
|
|
930
|
+
task-specific layers, so the runtime (the ``coder_eval_entrypoint.sh``
|
|
931
|
+
script + the ``coder-eval`` CLI + the ``org.coder-eval.version`` label)
|
|
932
|
+
is present. After building we assert that label is present and fail with
|
|
933
|
+
an actionable error otherwise -- without this, a bare ``FROM ubuntu``
|
|
934
|
+
image builds fine, then dies at ``docker run`` with a cryptic
|
|
935
|
+
``exec: "/usr/local/bin/coder_eval_entrypoint.sh": no such file``.
|
|
936
|
+
|
|
937
|
+
Side-effecting (network + docker daemon state); call via
|
|
938
|
+
``asyncio.to_thread`` from :meth:`run`, never from :meth:`_build_argv`,
|
|
939
|
+
which must stay pure.
|
|
940
|
+
|
|
941
|
+
Returns:
|
|
942
|
+
The image reference to pass to ``docker run``.
|
|
943
|
+
|
|
944
|
+
Raises:
|
|
945
|
+
DockerRunError: If ``docker build`` exits non-zero, or the built
|
|
946
|
+
image is not a coder-eval runtime image (missing the
|
|
947
|
+
``org.coder-eval.version`` label).
|
|
948
|
+
"""
|
|
949
|
+
cfg = self._docker_config
|
|
950
|
+
if not cfg.dockerfile_path:
|
|
951
|
+
return cfg.image
|
|
952
|
+
dockerfile = Path(cfg.dockerfile_path)
|
|
953
|
+
context = dockerfile.parent
|
|
954
|
+
# Image repository names must be lowercase; task ids are typically
|
|
955
|
+
# already kebab-case, but lowercase defensively. Deterministic tag ->
|
|
956
|
+
# Docker layer cache is reused across runs of the same task.
|
|
957
|
+
safe_id = _sanitize_container_name_component(self.rt.task.task_id).lower()
|
|
958
|
+
image = f"coder-eval-task-{safe_id}:built"
|
|
959
|
+
|
|
960
|
+
# Assemble the build argv from config: base flags, then task-supplied
|
|
961
|
+
# --build-arg / --secret / extra flags, then the context (always last).
|
|
962
|
+
build = cfg.build
|
|
963
|
+
argv = ["docker", "build", "-t", image, "-f", str(dockerfile)]
|
|
964
|
+
for key, value in build.args.items():
|
|
965
|
+
argv += ["--build-arg", f"{key}={os.path.expandvars(value)}"]
|
|
966
|
+
for spec in build.secrets:
|
|
967
|
+
argv += ["--secret", spec]
|
|
968
|
+
argv += build.extra_args
|
|
969
|
+
argv.append(str(context))
|
|
970
|
+
|
|
971
|
+
# BuildKit (required for `--secret`) is inherited from the invoking
|
|
972
|
+
# environment by default; `build.buildkit` forces it on/off when set.
|
|
973
|
+
env = os.environ.copy()
|
|
974
|
+
if build.buildkit is not None:
|
|
975
|
+
env["DOCKER_BUILDKIT"] = "1" if build.buildkit else "0"
|
|
976
|
+
if build.secrets and env.get("DOCKER_BUILDKIT") != "1":
|
|
977
|
+
logger.warning(
|
|
978
|
+
"docker.build.secrets is set but BuildKit is not enabled (DOCKER_BUILDKIT=%s); "
|
|
979
|
+
+ "secrets require BuildKit. Set docker.build.buildkit: true or export DOCKER_BUILDKIT=1.",
|
|
980
|
+
env.get("DOCKER_BUILDKIT", "<unset>"),
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
# Log only high-level info here.
|
|
984
|
+
logger.info("Building docker image %s from %s (context %s)", image, dockerfile, context)
|
|
985
|
+
try:
|
|
986
|
+
subprocess.run(argv, check=True, capture_output=True, text=True, encoding="utf-8", env=env)
|
|
987
|
+
except subprocess.CalledProcessError as exc:
|
|
988
|
+
# Preserve the full build output (stdout+stderr) so run() can persist
|
|
989
|
+
# it to docker.log; the message keeps the concise stderr tail.
|
|
990
|
+
build_log = (exc.stdout or "") + (exc.stderr or "")
|
|
991
|
+
raise DockerBuildError(
|
|
992
|
+
f"Failed to build Docker image from {dockerfile}: {exc.stderr}", build_log=build_log
|
|
993
|
+
) from exc
|
|
994
|
+
self._assert_runtime_image(image, dockerfile)
|
|
995
|
+
return image
|
|
996
|
+
|
|
997
|
+
def _assert_runtime_image(self, image: str, dockerfile: Path) -> None:
|
|
998
|
+
"""Fail fast unless the built image carries the coder-eval runtime.
|
|
999
|
+
|
|
1000
|
+
The host pins ``--entrypoint`` at run time, so we no longer inspect the
|
|
1001
|
+
baked ``ENTRYPOINT``; instead we verify the image is a coder-eval runtime
|
|
1002
|
+
image by checking for the ``org.coder-eval.version`` label, which
|
|
1003
|
+
docker/Dockerfile stamps and any ``FROM coder-eval-agent`` task inherits.
|
|
1004
|
+
This is the only pre-run validation for a ``dockerfile_path`` task
|
|
1005
|
+
(``run()`` skips :func:`_preflight_image_version` for that case), so
|
|
1006
|
+
without it a bare ``FROM ubuntu`` image would build, then die at
|
|
1007
|
+
``docker run`` with a cryptic ``exec ...coder_eval_entrypoint.sh: no
|
|
1008
|
+
such file``. A docker/inspect failure is soft (debug-logged, no raise):
|
|
1009
|
+
the subsequent ``docker run`` surfaces any real problem.
|
|
1010
|
+
|
|
1011
|
+
Raises:
|
|
1012
|
+
DockerRunError: If the image carries no ``org.coder-eval.version``
|
|
1013
|
+
label (i.e. it is not built ``FROM coder-eval-agent``).
|
|
1014
|
+
"""
|
|
1015
|
+
try:
|
|
1016
|
+
result = subprocess.run(
|
|
1017
|
+
[
|
|
1018
|
+
"docker",
|
|
1019
|
+
"image",
|
|
1020
|
+
"inspect",
|
|
1021
|
+
"--format",
|
|
1022
|
+
'{{ index .Config.Labels "org.coder-eval.version" }}',
|
|
1023
|
+
image,
|
|
1024
|
+
],
|
|
1025
|
+
check=True,
|
|
1026
|
+
capture_output=True,
|
|
1027
|
+
text=True,
|
|
1028
|
+
encoding="utf-8",
|
|
1029
|
+
timeout=10,
|
|
1030
|
+
)
|
|
1031
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as exc:
|
|
1032
|
+
logger.debug("Could not inspect labels of built image %s: %s", image, exc)
|
|
1033
|
+
return
|
|
1034
|
+
# `docker inspect` renders a missing label as the empty string (the Go
|
|
1035
|
+
# template's zero value); "<no value>" can occur on older clients.
|
|
1036
|
+
label = result.stdout.strip()
|
|
1037
|
+
if not label or label == "<no value>":
|
|
1038
|
+
base = get_default_docker_image_tag()
|
|
1039
|
+
raise DockerRunError(
|
|
1040
|
+
f"Image built from {dockerfile} is not a coder-eval runtime image "
|
|
1041
|
+
+ "(missing the org.coder-eval.version label). The container must run the "
|
|
1042
|
+
+ f"in-container orchestrator, so a task Dockerfile must start `FROM {base}` "
|
|
1043
|
+
+ "(the framework image, built via `make docker-image`) and only add "
|
|
1044
|
+
+ "task-specific layers on top. See docs/DOCKER_ISOLATION.md."
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
def _build_argv(
|
|
1048
|
+
self, input_dir: Path, output_dir: Path, *, container_name: str, image: str | None = None
|
|
1049
|
+
) -> list[str]:
|
|
1050
|
+
cfg = self._docker_config
|
|
1051
|
+
# `image` is resolved by run() via _build_image() (which may shell out to
|
|
1052
|
+
# `docker build`). _build_argv stays pure -- no side effects -- so it
|
|
1053
|
+
# remains testable without a docker daemon. Fall back to the configured
|
|
1054
|
+
# image when called directly (e.g. unit tests of mount rendering).
|
|
1055
|
+
if image is None:
|
|
1056
|
+
image = cfg.image
|
|
1057
|
+
|
|
1058
|
+
argv: list[str] = ["docker", "run", "--rm", "--name", container_name]
|
|
1059
|
+
|
|
1060
|
+
# Pin the framework entrypoint at run time rather than trusting whatever
|
|
1061
|
+
# the task image baked into ENTRYPOINT. This makes the orchestrator launch
|
|
1062
|
+
# robust to a task Dockerfile that sets its own ENTRYPOINT/CMD (or clears
|
|
1063
|
+
# it via `ENTRYPOINT []`). `--entrypoint` resets the image CMD, which is
|
|
1064
|
+
# fine -- the run command (`--output`/`--task-dir`, appended after the
|
|
1065
|
+
# image) is passed explicitly below and is forwarded to the entrypoint.
|
|
1066
|
+
argv += ["--entrypoint", CONTAINER_ENTRYPOINT]
|
|
1067
|
+
|
|
1068
|
+
if cfg.network == "none":
|
|
1069
|
+
argv += ["--network", "none"]
|
|
1070
|
+
else:
|
|
1071
|
+
argv += ["--network", "bridge"]
|
|
1072
|
+
|
|
1073
|
+
if self._limits.max_memory_mb:
|
|
1074
|
+
argv += ["--memory", f"{self._limits.max_memory_mb}m"]
|
|
1075
|
+
if self._limits.max_cpus is not None:
|
|
1076
|
+
argv += ["--cpus", str(self._limits.max_cpus)]
|
|
1077
|
+
if self._limits.max_pids is not None:
|
|
1078
|
+
argv += ["--pids-limit", str(self._limits.max_pids)]
|
|
1079
|
+
|
|
1080
|
+
# Forward environment variables: explicit allowlist (optionally extended via env_passthrough_extra).
|
|
1081
|
+
# `--env VAR` (name-only) tells docker to copy the value from our current env at
|
|
1082
|
+
# run time, so secrets stay out of the rendered argv list that we log.
|
|
1083
|
+
#
|
|
1084
|
+
# The run's backend rides this same path: API_BACKEND is in the default allowlist,
|
|
1085
|
+
# and `--backend` syncs it into os.environ at the CLI (run_command), so it forwards
|
|
1086
|
+
# here exactly like every other allowlisted var. A flag that only mutated in-process
|
|
1087
|
+
# Settings would be dropped at the container boundary and the in-container Settings
|
|
1088
|
+
# would silently default to DIRECT — downgrading the judge (and agent) route.
|
|
1089
|
+
merged_allowlist = set(cfg.env_passthrough) | set(cfg.env_passthrough_extra)
|
|
1090
|
+
for env_var in merged_allowlist:
|
|
1091
|
+
if env_var in os.environ:
|
|
1092
|
+
argv += ["--env", env_var]
|
|
1093
|
+
|
|
1094
|
+
# Signal to in-container agents that the harness already provides OS-level
|
|
1095
|
+
# isolation. The Codex agent reads this to fall back to its full-access
|
|
1096
|
+
# sandbox: Codex's Landlock-backed read-only / workspace-write sandboxes
|
|
1097
|
+
# can't initialize inside a container and otherwise fail writes silently.
|
|
1098
|
+
argv += ["--env", "CODER_EVAL_IN_CONTAINER=1"]
|
|
1099
|
+
|
|
1100
|
+
# Hard-disable telemetry INSIDE the container. The app ships a baked-in
|
|
1101
|
+
# default connection string, so without this the in-container orchestrator
|
|
1102
|
+
# would emit CoderEval.Task.End — and the host re-emits the same event after
|
|
1103
|
+
# the container result is parsed (orchestration/batch.py), double-counting
|
|
1104
|
+
# every docker-driver task. The invariant is "container silent, host emits
|
|
1105
|
+
# once"; this restores it regardless of the host's own telemetry setting.
|
|
1106
|
+
# Explicit value (not name-only) so it overrides any inherited/baked value.
|
|
1107
|
+
argv += ["--env", "TELEMETRY_ENABLED=false"]
|
|
1108
|
+
|
|
1109
|
+
argv += ["-v", f"{input_dir.resolve()}:{CONTAINER_INPUT_DIR}:ro"]
|
|
1110
|
+
# Mount the host run_dir to the container's standard output location
|
|
1111
|
+
# so the in-container Orchestrator writes task.json/task.log/etc.
|
|
1112
|
+
# directly to the host filesystem via bind-mount.
|
|
1113
|
+
argv += ["-v", f"{output_dir}:{CONTAINER_OUTPUT_DIR}"]
|
|
1114
|
+
# Mount the original task dir at the SAME host path so the
|
|
1115
|
+
# in-container Orchestrator can set TASK_DIR (used by run_command
|
|
1116
|
+
# criteria via `$TASK_DIR/foo.json`) to a path that resolves
|
|
1117
|
+
# identically inside and outside the container.
|
|
1118
|
+
host_task_dir: Path | None = None
|
|
1119
|
+
if self.rt.task_file:
|
|
1120
|
+
host_task_dir = self.rt.task_file.parent.resolve()
|
|
1121
|
+
argv += ["-v", f"{host_task_dir}:{host_task_dir}:ro"]
|
|
1122
|
+
# Forward the host's Claude Code OAuth state so the in-container CLI
|
|
1123
|
+
# inherits the same login as the host. We mount a *throwaway lean copy*
|
|
1124
|
+
# of ~/.claude (made by _prepare_host_mounts) read-WRITE at the host's
|
|
1125
|
+
# ~/.claude path — HOME is forwarded, so the path is symmetric inside
|
|
1126
|
+
# the container. The container can therefore write anywhere under
|
|
1127
|
+
# ~/.claude (settings, session ephemera, cache) without ever mutating
|
|
1128
|
+
# the host's real ~/.claude. _claude_mount_src is None when ~/.claude
|
|
1129
|
+
# doesn't exist or the mount is opted out (CODER_EVAL_NO_CLAUDE_MOUNT=1).
|
|
1130
|
+
if self._claude_mount_src is not None:
|
|
1131
|
+
host_claude_dir = Path.home() / ".claude"
|
|
1132
|
+
argv += ["-v", f"{self._claude_mount_src}:{host_claude_dir}"]
|
|
1133
|
+
|
|
1134
|
+
# Auto-mount host paths the task references so they resolve inside
|
|
1135
|
+
# the container at the *same* path they have on the host.
|
|
1136
|
+
# Includes:
|
|
1137
|
+
# - Claude Code plugin dirs (`agent.plugins[].path`)
|
|
1138
|
+
# - Template directories (`sandbox.template_sources[].path` for
|
|
1139
|
+
# TemplateDirSource entries -- already absolute after
|
|
1140
|
+
# resolve_template_paths runs on the host).
|
|
1141
|
+
# Reference files (`task.reference.file`) and `run_command`
|
|
1142
|
+
# criteria that use `$TASK_DIR/...` are covered by the symmetric
|
|
1143
|
+
# task_dir mount above. ``mounted`` dedupes overlapping entries.
|
|
1144
|
+
mounted: set[Path] = set()
|
|
1145
|
+
# Auto-mount sources that look like credential / secret dirs get a
|
|
1146
|
+
# loud warning. Task YAMLs typically come from in-house suite authors,
|
|
1147
|
+
# but the `plugin.path` / `reference.directory` / `template_sources`
|
|
1148
|
+
# fields are user-controlled strings, and a typo (or a hostile suite)
|
|
1149
|
+
# can silently expose `~/.ssh` etc. Warning, not hard fail, because
|
|
1150
|
+
# legitimate uses exist (a task that does in fact want to read
|
|
1151
|
+
# `~/.aws/config`). The warning surfaces the surprise.
|
|
1152
|
+
sensitive_sources = self._sensitive_source_paths()
|
|
1153
|
+
|
|
1154
|
+
def _auto_mount(raw_path: str | None, *, dir_only: bool = True) -> None:
|
|
1155
|
+
if not raw_path:
|
|
1156
|
+
return
|
|
1157
|
+
resolved = Path(os.path.expandvars(os.path.expanduser(raw_path))).resolve()
|
|
1158
|
+
# File paths get mounted as the parent dir so a single -v covers
|
|
1159
|
+
# the file; container-side reads still resolve at the same path.
|
|
1160
|
+
target = resolved if (dir_only or resolved.is_dir()) else resolved.parent
|
|
1161
|
+
if target in mounted or not target.is_dir():
|
|
1162
|
+
return
|
|
1163
|
+
for sensitive in sensitive_sources:
|
|
1164
|
+
if target == sensitive or sensitive in target.parents:
|
|
1165
|
+
logger.warning(
|
|
1166
|
+
"Auto-mounting sensitive host path %s into container; fix task YAML if unintended.",
|
|
1167
|
+
target,
|
|
1168
|
+
)
|
|
1169
|
+
break
|
|
1170
|
+
mounted.add(target)
|
|
1171
|
+
argv.extend(["-v", f"{target}:{target}:ro"])
|
|
1172
|
+
|
|
1173
|
+
plugins = (self.rt.task.agent.plugins if self.rt.task.agent else None) or []
|
|
1174
|
+
for plugin in plugins:
|
|
1175
|
+
_auto_mount(plugin.get("path") if isinstance(plugin, dict) else None)
|
|
1176
|
+
|
|
1177
|
+
from coder_eval.models import TemplateDirSource
|
|
1178
|
+
|
|
1179
|
+
sandbox_cfg = self.rt.task.sandbox
|
|
1180
|
+
for source in (sandbox_cfg.template_sources or []) if sandbox_cfg else []:
|
|
1181
|
+
if isinstance(source, TemplateDirSource):
|
|
1182
|
+
_auto_mount(source.path)
|
|
1183
|
+
|
|
1184
|
+
# Defensive: system_prompt_file is normally inlined into
|
|
1185
|
+
# system_prompt by load_task / experiment resolution, but a variant
|
|
1186
|
+
# could conceivably inject an absolute path that survives. Cover
|
|
1187
|
+
# that path so the in-container Orchestrator can read it.
|
|
1188
|
+
agent_cfg = self.rt.task.agent
|
|
1189
|
+
if agent_cfg and agent_cfg.system_prompt_file:
|
|
1190
|
+
_auto_mount(agent_cfg.system_prompt_file, dir_only=False)
|
|
1191
|
+
|
|
1192
|
+
# reference.file / reference.directory: if a task ships absolute
|
|
1193
|
+
# paths (or relative paths that escape the task_dir mount via
|
|
1194
|
+
# ``..``), they must be mounted explicitly. Relative paths under
|
|
1195
|
+
# task_dir are already covered by the symmetric task_dir mount.
|
|
1196
|
+
reference = self.rt.task.reference
|
|
1197
|
+
if reference is not None:
|
|
1198
|
+
_auto_mount(reference.file, dir_only=False)
|
|
1199
|
+
_auto_mount(reference.directory)
|
|
1200
|
+
for mount in cfg.extra_mounts:
|
|
1201
|
+
normalized = _validate_extra_mount(mount)
|
|
1202
|
+
argv += ["-v", normalized]
|
|
1203
|
+
|
|
1204
|
+
# Docker WORKDIR alignment: run the agent at the image's own WORKDIR. Set
|
|
1205
|
+
# the container's initial cwd via `-w` (the in-container orchestrator also
|
|
1206
|
+
# runs the agent there). NO bind mount targets it -- capture is a copy-out
|
|
1207
|
+
# (see Orchestrator._cleanup), not a mount, so baked inputs/HOME survive.
|
|
1208
|
+
if self._workspace_dir is not None:
|
|
1209
|
+
_assert_workspace_not_reserved(self._workspace_dir)
|
|
1210
|
+
argv += ["-w", self._workspace_dir]
|
|
1211
|
+
|
|
1212
|
+
argv += [image]
|
|
1213
|
+
# Pass the container-side output path (the input/output are bound at
|
|
1214
|
+
# container-side defaults, so we just use those).
|
|
1215
|
+
if self.verbose:
|
|
1216
|
+
argv += ["-v"]
|
|
1217
|
+
argv += ["--output", str(CONTAINER_OUTPUT_DIR)]
|
|
1218
|
+
if host_task_dir is not None:
|
|
1219
|
+
argv += ["--task-dir", str(host_task_dir)]
|
|
1220
|
+
return argv
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
def build_error_result(
|
|
1224
|
+
rt: ResolvedTask, exc: BaseException, *, status: FinalStatus = FinalStatus.ERROR
|
|
1225
|
+
) -> EvaluationResult:
|
|
1226
|
+
"""Synthesize an error-status EvaluationResult for a Docker-runner failure.
|
|
1227
|
+
|
|
1228
|
+
Mirrors the shape produced by ``_create_error_task_result`` in
|
|
1229
|
+
``orchestration.batch`` so downstream reporting code doesn't have to
|
|
1230
|
+
special-case Docker failures. ``status`` lets the caller distinguish a
|
|
1231
|
+
failed image build (:data:`FinalStatus.BUILD_FAILED`) from a generic ERROR;
|
|
1232
|
+
for a build failure the full build log is carried into ``error_log_tail``.
|
|
1233
|
+
"""
|
|
1234
|
+
build_log = getattr(exc, "build_log", "") or ""
|
|
1235
|
+
description = (
|
|
1236
|
+
"Docker image build failed"
|
|
1237
|
+
if status == FinalStatus.BUILD_FAILED
|
|
1238
|
+
else f"Docker run failed: {type(exc).__name__}"
|
|
1239
|
+
)
|
|
1240
|
+
return EvaluationResult(
|
|
1241
|
+
task_id=rt.task.task_id,
|
|
1242
|
+
task_description=description,
|
|
1243
|
+
variant_id=rt.variant_id,
|
|
1244
|
+
agent_type=AgentKind.UNKNOWN,
|
|
1245
|
+
started_at=datetime.now(),
|
|
1246
|
+
final_status=status,
|
|
1247
|
+
error_message=str(exc),
|
|
1248
|
+
# Match the orchestrator's task.log tail ceiling; docker.log keeps the
|
|
1249
|
+
# full unbounded build output regardless.
|
|
1250
|
+
error_log_tail=build_log[-DEFAULT_LOG_TAIL_MAX_BYTES:] if build_log else None,
|
|
1251
|
+
iteration_count=0,
|
|
1252
|
+
environment_info={},
|
|
1253
|
+
)
|