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,690 @@
|
|
|
1
|
+
"""Batch execution support for running resolved tasks in parallel.
|
|
2
|
+
|
|
3
|
+
This module provides the unified run_batch() function that accepts pre-resolved
|
|
4
|
+
tasks (list[ResolvedTask]) and executes them with concurrency control,
|
|
5
|
+
exception handling, and result aggregation.
|
|
6
|
+
|
|
7
|
+
Task loading, config resolution, and CLI override application are handled
|
|
8
|
+
upstream by resolve_all_tasks() in experiment.py.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import shutil
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from ..models import (
|
|
21
|
+
AgentKind,
|
|
22
|
+
EvaluationResult,
|
|
23
|
+
FinalStatus,
|
|
24
|
+
PreservationMode,
|
|
25
|
+
ResolvedTask,
|
|
26
|
+
RunSummary,
|
|
27
|
+
SkippedTask,
|
|
28
|
+
TaskDefinition,
|
|
29
|
+
TaskResult,
|
|
30
|
+
)
|
|
31
|
+
from ..path_utils import format_task_log_id
|
|
32
|
+
from ..reports_experiment import eval_result_to_task_dict
|
|
33
|
+
from ..streaming.callbacks import StreamCallback
|
|
34
|
+
from ..utils import get_version_info, looks_like_version
|
|
35
|
+
from .config import BatchRunConfig, resolve_preservation_mode
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def run_batch(
|
|
42
|
+
resolved_tasks: list[ResolvedTask],
|
|
43
|
+
config: BatchRunConfig,
|
|
44
|
+
on_task_complete: Callable[[TaskResult], None] | None = None,
|
|
45
|
+
on_batch_start: Callable[[int], None] | None = None,
|
|
46
|
+
stream_callback_factory: Callable[[str], StreamCallback] | None = None,
|
|
47
|
+
skipped_tasks: list[SkippedTask] | None = None,
|
|
48
|
+
prior_results: list[TaskResult] | None = None,
|
|
49
|
+
prior_resolved: list[ResolvedTask] | None = None,
|
|
50
|
+
) -> tuple[RunSummary, list[TaskResult]]:
|
|
51
|
+
"""Run resolved tasks in batch with optional parallelism.
|
|
52
|
+
|
|
53
|
+
Tasks must be fully resolved (all config layers applied, tag filtering done).
|
|
54
|
+
This function is a pure executor — no configuration or loading logic.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
resolved_tasks: List of fully-resolved tasks from resolve_all_tasks.
|
|
58
|
+
config: Batch configuration (max_parallel, preservation_mode, run_dir).
|
|
59
|
+
on_task_complete: Optional callback invoked after each task finishes.
|
|
60
|
+
on_batch_start: Optional callback invoked with the final task count.
|
|
61
|
+
stream_callback_factory: Optional factory for streaming callbacks.
|
|
62
|
+
skipped_tasks: Task YAMLs that failed to load upstream and should be
|
|
63
|
+
recorded in the run summary (informational; they don't run).
|
|
64
|
+
prior_results: Already-complete TaskResults (loaded from disk on
|
|
65
|
+
--resume) to fold into run.json so the summary covers the whole
|
|
66
|
+
run, not just this batch. These are not re-executed.
|
|
67
|
+
prior_resolved: ResolvedTasks matching prior_results — used only to
|
|
68
|
+
populate tags/source-path in the summary's per-task entries.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Tuple of (RunSummary, list[TaskResult]) — results cover this batch
|
|
72
|
+
plus any prior_results.
|
|
73
|
+
"""
|
|
74
|
+
from ..orchestrator import Orchestrator
|
|
75
|
+
|
|
76
|
+
start_time = datetime.now()
|
|
77
|
+
|
|
78
|
+
if on_batch_start is not None:
|
|
79
|
+
on_batch_start(len(resolved_tasks))
|
|
80
|
+
|
|
81
|
+
semaphore = asyncio.Semaphore(config.max_parallel)
|
|
82
|
+
# Tags/paths cover both freshly-run and resumed-prior tasks so every entry
|
|
83
|
+
# in run.json carries its metadata.
|
|
84
|
+
metadata_tasks = [*resolved_tasks, *(prior_resolved or [])]
|
|
85
|
+
task_tags: dict[str, list[str]] = {rt.task.task_id: rt.task.tags for rt in metadata_tasks}
|
|
86
|
+
task_paths: dict[str, str] = {rt.task.task_id: str(rt.task_file) for rt in metadata_tasks}
|
|
87
|
+
|
|
88
|
+
async def run_single(rt: ResolvedTask) -> TaskResult:
|
|
89
|
+
"""Run a single resolved task with semaphore for concurrency control."""
|
|
90
|
+
stream_label = format_task_log_id(rt.variant_id, rt.task.task_id, rt.replicate_index)
|
|
91
|
+
task_callback = stream_callback_factory(stream_label) if stream_callback_factory else None
|
|
92
|
+
async with semaphore:
|
|
93
|
+
try:
|
|
94
|
+
rt.run_dir.mkdir(parents=True, exist_ok=True) # noqa: CE002 — mkdir on local FS is nanoseconds
|
|
95
|
+
sandbox_cfg = rt.task.sandbox
|
|
96
|
+
# Resolve the driver-derived preservation default HERE, where the
|
|
97
|
+
# original driver is still visible (the in-container orchestrator
|
|
98
|
+
# sees it forced to tempdir). Explicit --preservation-mode wins.
|
|
99
|
+
driver = sandbox_cfg.driver if sandbox_cfg is not None else "tempdir"
|
|
100
|
+
preservation_mode = resolve_preservation_mode(config.preservation_mode, driver)
|
|
101
|
+
# Explicit DIRECT_WRITE on a non-docker host runs the sandbox under
|
|
102
|
+
# run_dir, re-opening the parent-dir node_modules contamination the
|
|
103
|
+
# host default (MOVE_ON_WRITE) exists to prevent (MST-9795).
|
|
104
|
+
if config.preservation_mode == PreservationMode.DIRECT_WRITE and driver != "docker":
|
|
105
|
+
logger.warning(
|
|
106
|
+
"DIRECT_WRITE on driver=%s runs the sandbox under run_dir; parent-dir "
|
|
107
|
+
+ "node_modules can contaminate Node tool resolution (MST-9795).",
|
|
108
|
+
driver,
|
|
109
|
+
)
|
|
110
|
+
if sandbox_cfg is not None and sandbox_cfg.driver == "docker":
|
|
111
|
+
# Docker isolation: spawn one container per task, parse
|
|
112
|
+
# its task.json on completion. The in-container CLI
|
|
113
|
+
# serializes stream events as NDJSON on stdout; we
|
|
114
|
+
# forward them to the host callback so --stream
|
|
115
|
+
# renders identically to the in-process path.
|
|
116
|
+
from ..isolation.docker_runner import DockerRunner
|
|
117
|
+
|
|
118
|
+
result = await DockerRunner(
|
|
119
|
+
rt,
|
|
120
|
+
preservation_mode=preservation_mode,
|
|
121
|
+
stream_callback=task_callback,
|
|
122
|
+
verbose=config.verbose,
|
|
123
|
+
).run()
|
|
124
|
+
# The in-container _finalize_result can't emit task telemetry
|
|
125
|
+
# (connection-string env vars aren't forwarded into the
|
|
126
|
+
# container), so emit the Task.End event host-side here
|
|
127
|
+
# — keeping docker runs at parity with the in-process path.
|
|
128
|
+
from ..orchestrator import build_task_event
|
|
129
|
+
from ..telemetry import track_event
|
|
130
|
+
|
|
131
|
+
name, props = build_task_event(result, driver="docker", variant_id=rt.variant_id)
|
|
132
|
+
track_event(name, props)
|
|
133
|
+
else:
|
|
134
|
+
orchestrator = Orchestrator(
|
|
135
|
+
task=rt.task,
|
|
136
|
+
run_dir=rt.run_dir,
|
|
137
|
+
preservation_mode=preservation_mode,
|
|
138
|
+
task_file=rt.task_file,
|
|
139
|
+
stream_callback=task_callback,
|
|
140
|
+
variant_id=rt.variant_id,
|
|
141
|
+
source_yaml=rt.source_yaml,
|
|
142
|
+
config_lineage=rt.config_lineage,
|
|
143
|
+
replicate_index=rt.replicate_index,
|
|
144
|
+
)
|
|
145
|
+
result = await orchestrator.run()
|
|
146
|
+
tr = TaskResult(
|
|
147
|
+
task_id=rt.task.task_id,
|
|
148
|
+
variant_id=rt.variant_id,
|
|
149
|
+
result=result,
|
|
150
|
+
duration=result.duration_seconds,
|
|
151
|
+
suite_id=rt.task.suite_id,
|
|
152
|
+
row_id=rt.task.row_id,
|
|
153
|
+
replicate_index=rt.replicate_index,
|
|
154
|
+
)
|
|
155
|
+
except (KeyboardInterrupt, SystemExit):
|
|
156
|
+
raise
|
|
157
|
+
except Exception as exc:
|
|
158
|
+
tr = _create_error_task_result(
|
|
159
|
+
rt.task_file,
|
|
160
|
+
exc,
|
|
161
|
+
task_id=rt.task.task_id,
|
|
162
|
+
variant_id=rt.variant_id,
|
|
163
|
+
suite_id=rt.task.suite_id,
|
|
164
|
+
row_id=rt.task.row_id,
|
|
165
|
+
replicate_index=rt.replicate_index,
|
|
166
|
+
)
|
|
167
|
+
_safe_notify(on_task_complete, tr)
|
|
168
|
+
return tr
|
|
169
|
+
|
|
170
|
+
coroutines = [run_single(rt) for rt in resolved_tasks]
|
|
171
|
+
results: list[TaskResult | BaseException] = await asyncio.gather(*coroutines, return_exceptions=True)
|
|
172
|
+
|
|
173
|
+
# Re-raise fatal exceptions before processing results
|
|
174
|
+
for result in results:
|
|
175
|
+
if isinstance(result, (KeyboardInterrupt, SystemExit)):
|
|
176
|
+
raise result
|
|
177
|
+
|
|
178
|
+
processed: list[TaskResult] = []
|
|
179
|
+
for i, result in enumerate(results):
|
|
180
|
+
if isinstance(result, BaseException):
|
|
181
|
+
rt = resolved_tasks[i]
|
|
182
|
+
processed.append(
|
|
183
|
+
_create_error_task_result(
|
|
184
|
+
rt.task_file,
|
|
185
|
+
result,
|
|
186
|
+
task_id=rt.task.task_id,
|
|
187
|
+
variant_id=rt.variant_id,
|
|
188
|
+
suite_id=rt.task.suite_id,
|
|
189
|
+
row_id=rt.task.row_id,
|
|
190
|
+
replicate_index=rt.replicate_index,
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
else:
|
|
194
|
+
processed.append(result)
|
|
195
|
+
|
|
196
|
+
end_time = datetime.now()
|
|
197
|
+
# Fold in any already-complete results (--resume) so run.json and all
|
|
198
|
+
# downstream reports describe the whole run, not just this batch.
|
|
199
|
+
all_results = [*(prior_results or []), *processed]
|
|
200
|
+
summary = _generate_run_summary(
|
|
201
|
+
config.run_dir,
|
|
202
|
+
all_results,
|
|
203
|
+
start_time,
|
|
204
|
+
end_time,
|
|
205
|
+
task_tags,
|
|
206
|
+
task_paths=task_paths,
|
|
207
|
+
max_parallel=config.max_parallel,
|
|
208
|
+
skipped_tasks=skipped_tasks or [],
|
|
209
|
+
)
|
|
210
|
+
return summary, all_results
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _safe_notify(callback: Callable[[TaskResult], None] | None, result: TaskResult) -> None:
|
|
214
|
+
"""Invoke a progress callback, swallowing any exceptions so UI failures never affect task outcomes."""
|
|
215
|
+
if callback is None:
|
|
216
|
+
return
|
|
217
|
+
try:
|
|
218
|
+
callback(result)
|
|
219
|
+
except Exception:
|
|
220
|
+
logger.warning("Progress callback failed (ignored)", exc_info=True)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _create_error_task_result(
|
|
224
|
+
task_file: Path,
|
|
225
|
+
error: BaseException,
|
|
226
|
+
*,
|
|
227
|
+
task_id: str | None = None,
|
|
228
|
+
variant_id: str,
|
|
229
|
+
suite_id: str | None = None,
|
|
230
|
+
row_id: str | None = None,
|
|
231
|
+
replicate_index: int = 0,
|
|
232
|
+
) -> TaskResult:
|
|
233
|
+
"""Create a TaskResult for a failed task.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
task_file: Path to task file that failed.
|
|
237
|
+
error: Exception that was raised.
|
|
238
|
+
task_id: Explicit task ID; falls back to task_file.stem when unavailable.
|
|
239
|
+
variant_id: Experiment variant ID.
|
|
240
|
+
suite_id: Parent suite id (dataset expansion).
|
|
241
|
+
row_id: Row id within the suite.
|
|
242
|
+
replicate_index: Replicate index from ResolvedTask.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
TaskResult with error information.
|
|
246
|
+
"""
|
|
247
|
+
error_type = type(error).__name__
|
|
248
|
+
# A failed image build is an environment/setup failure — record it as
|
|
249
|
+
# BUILD_FAILED (not generic ERROR) so reports/run.json distinguish it. The
|
|
250
|
+
# import is lazy + scoped to this except-path use so batch stays import-light
|
|
251
|
+
# and the non-docker path never imports the docker runner.
|
|
252
|
+
from ..isolation.docker_runner import DockerBuildError
|
|
253
|
+
|
|
254
|
+
is_build_failure = isinstance(error, DockerBuildError)
|
|
255
|
+
status = FinalStatus.BUILD_FAILED if is_build_failure else FinalStatus.ERROR
|
|
256
|
+
description = (
|
|
257
|
+
"Docker image build failed" if is_build_failure else f"Failed to load task from {task_file}: {error_type}"
|
|
258
|
+
)
|
|
259
|
+
error_result = EvaluationResult(
|
|
260
|
+
task_id=task_id if task_id is not None else task_file.stem,
|
|
261
|
+
task_description=description,
|
|
262
|
+
variant_id=variant_id,
|
|
263
|
+
agent_type=AgentKind.UNKNOWN,
|
|
264
|
+
started_at=datetime.now(),
|
|
265
|
+
final_status=status,
|
|
266
|
+
error_message=str(error),
|
|
267
|
+
iteration_count=0,
|
|
268
|
+
environment_info={},
|
|
269
|
+
)
|
|
270
|
+
return TaskResult(
|
|
271
|
+
task_id=error_result.task_id,
|
|
272
|
+
variant_id=error_result.variant_id,
|
|
273
|
+
result=error_result,
|
|
274
|
+
duration=0.0,
|
|
275
|
+
suite_id=suite_id,
|
|
276
|
+
row_id=row_id,
|
|
277
|
+
replicate_index=replicate_index,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def partition_for_resume(
|
|
282
|
+
resolved_tasks: list[ResolvedTask],
|
|
283
|
+
) -> tuple[list[ResolvedTask], list[TaskResult], list[ResolvedTask]]:
|
|
284
|
+
"""Split resolved tasks into (to_run, prior_results, prior_resolved) for --resume.
|
|
285
|
+
|
|
286
|
+
A task is already-complete when its task.json exists, parses, and carries a
|
|
287
|
+
final_status. task.json is written atomically at end-of-run, so any parseable
|
|
288
|
+
file with a status is a finished task (no partial-write ambiguity). Complete
|
|
289
|
+
tasks are reloaded into TaskResults (to fold into run.json) and excluded from
|
|
290
|
+
to_run; everything else — including failed-to-parse — re-runs.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
resolved_tasks: Fully-resolved tasks for the whole run.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
(to_run, prior_results, prior_resolved):
|
|
297
|
+
- to_run: tasks still needing execution
|
|
298
|
+
- prior_results: reloaded results for already-complete tasks
|
|
299
|
+
- prior_resolved: the ResolvedTask for each prior_result (same order)
|
|
300
|
+
"""
|
|
301
|
+
to_run: list[ResolvedTask] = []
|
|
302
|
+
prior_results: list[TaskResult] = []
|
|
303
|
+
prior_resolved: list[ResolvedTask] = []
|
|
304
|
+
for rt in resolved_tasks:
|
|
305
|
+
tr = _load_completed_result(rt)
|
|
306
|
+
if tr is None:
|
|
307
|
+
to_run.append(rt)
|
|
308
|
+
else:
|
|
309
|
+
prior_results.append(tr)
|
|
310
|
+
prior_resolved.append(rt)
|
|
311
|
+
return to_run, prior_results, prior_resolved
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def clear_rerun_artifacts(to_run: list[ResolvedTask]) -> int:
|
|
315
|
+
"""Remove stale ``artifacts/<task_id>`` dirs for tasks about to re-run under --resume.
|
|
316
|
+
|
|
317
|
+
A task in ``to_run`` is non-finalized (``partition_for_resume`` excluded every
|
|
318
|
+
finalized task), so it re-executes from scratch and any leftover artifacts are
|
|
319
|
+
unwanted. Only DIRECT_WRITE writes into ``artifacts/<task_id>`` live (a container
|
|
320
|
+
killed mid-run leaves partial files there); MOVE_ON_WRITE/NONE only create it at
|
|
321
|
+
finalize, which a non-finalized task never reached — so a pre-existing dir here is
|
|
322
|
+
always a stale DIRECT_WRITE partial. Clearing it prevents stale files from
|
|
323
|
+
satisfying file-based criteria and skewing the resumed task's score. Host-side, so
|
|
324
|
+
it covers both the docker bind-mount and the tempdir path. Returns the count cleared.
|
|
325
|
+
"""
|
|
326
|
+
cleared = 0
|
|
327
|
+
for rt in to_run:
|
|
328
|
+
artifacts = rt.run_dir / "artifacts" / rt.task.task_id
|
|
329
|
+
if artifacts.exists():
|
|
330
|
+
shutil.rmtree(artifacts, ignore_errors=True)
|
|
331
|
+
cleared += 1
|
|
332
|
+
return cleared
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _load_completed_result(rt: ResolvedTask) -> TaskResult | None:
|
|
336
|
+
"""Reconstruct a TaskResult from a finalized task.json, or None if absent/incomplete."""
|
|
337
|
+
report_path = rt.run_dir / "task.json"
|
|
338
|
+
try:
|
|
339
|
+
text = report_path.read_text(encoding="utf-8")
|
|
340
|
+
except OSError:
|
|
341
|
+
return None
|
|
342
|
+
try:
|
|
343
|
+
result = EvaluationResult.model_validate_json(text)
|
|
344
|
+
except ValueError:
|
|
345
|
+
# Malformed JSON or schema mismatch (pydantic ValidationError subclasses
|
|
346
|
+
# ValueError) → treat as not-yet-complete so the task re-runs.
|
|
347
|
+
return None
|
|
348
|
+
if not result.final_status:
|
|
349
|
+
return None
|
|
350
|
+
return TaskResult(
|
|
351
|
+
task_id=rt.task.task_id,
|
|
352
|
+
variant_id=rt.variant_id,
|
|
353
|
+
result=result,
|
|
354
|
+
duration=result.duration_seconds or 0.0,
|
|
355
|
+
suite_id=rt.task.suite_id,
|
|
356
|
+
row_id=rt.task.row_id,
|
|
357
|
+
replicate_index=rt.replicate_index,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def recover_task_results(run_dir: Path) -> list[TaskResult]:
|
|
362
|
+
"""Reconstruct ``TaskResult``s from every finalized ``task.json`` under ``run_dir``.
|
|
363
|
+
|
|
364
|
+
The disk half of the run-summary seam: pairs with ``build_run_summary`` to
|
|
365
|
+
(re)aggregate a finished run without re-executing it. Each ``task.json`` is the
|
|
366
|
+
atomically-written ``EvaluationResult`` for one task, so a file that is missing,
|
|
367
|
+
unparseable, or carries no ``final_status`` is skipped as not-yet-finalized
|
|
368
|
+
(mirrors ``_load_completed_result``). ``task_id`` and ``variant_id`` come from
|
|
369
|
+
the result itself; ``replicate_index`` is recovered from the
|
|
370
|
+
``<variant_id>/<task_id>/<NN>/`` layout (``build_task_run_dir``). ``suite_id`` /
|
|
371
|
+
``row_id`` are not stored in ``task.json`` and are left ``None`` — they feed
|
|
372
|
+
suite rollups, not ``run.json``.
|
|
373
|
+
|
|
374
|
+
Results are sorted by ``(variant_id, task_id, replicate_index)`` so the rebuilt
|
|
375
|
+
``run.json`` ordering is deterministic, independent of filesystem walk order.
|
|
376
|
+
|
|
377
|
+
A ``task.json`` that lives under a *nested* run dir — a subdirectory carrying its
|
|
378
|
+
own ``run.json`` — belongs to that sub-run, not this one, and is excluded so a
|
|
379
|
+
parent summary never absorbs a nested suite's tasks. Base runs have no nesting;
|
|
380
|
+
this only matters for composed layouts that stack sub-runs under one tree.
|
|
381
|
+
"""
|
|
382
|
+
nested_roots = [p.parent for p in run_dir.rglob("run.json") if p.parent != run_dir]
|
|
383
|
+
recovered: list[TaskResult] = []
|
|
384
|
+
for task_json in run_dir.rglob("task.json"):
|
|
385
|
+
if any(root in task_json.parents for root in nested_roots):
|
|
386
|
+
continue # belongs to a nested sub-run (its own run.json), not this one
|
|
387
|
+
try:
|
|
388
|
+
result = EvaluationResult.model_validate_json(task_json.read_text(encoding="utf-8"))
|
|
389
|
+
except (OSError, ValueError) as exc:
|
|
390
|
+
# Unreadable / malformed / schema-mismatched. Skip so one corrupt file can't
|
|
391
|
+
# abort the rebuild, but warn — silently dropping it would shrink tasks_run
|
|
392
|
+
# (numerator and denominator) with no signal that a task was lost.
|
|
393
|
+
logger.warning("skipping unreadable/malformed task.json %s: %s", task_json, exc)
|
|
394
|
+
continue
|
|
395
|
+
if not result.final_status:
|
|
396
|
+
# Written but incomplete (no terminal status) → skip, same as --resume.
|
|
397
|
+
continue
|
|
398
|
+
# The leaf dir is the zero-padded replicate index (build_task_run_dir);
|
|
399
|
+
# tolerate non-standard layouts by falling back to replicate 0.
|
|
400
|
+
leaf = task_json.parent.name
|
|
401
|
+
replicate_index = int(leaf) if leaf.isdigit() else 0
|
|
402
|
+
recovered.append(
|
|
403
|
+
TaskResult(
|
|
404
|
+
task_id=result.task_id,
|
|
405
|
+
variant_id=result.variant_id,
|
|
406
|
+
result=result,
|
|
407
|
+
duration=result.duration_seconds or 0.0,
|
|
408
|
+
replicate_index=replicate_index,
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
recovered.sort(key=lambda r: (r.variant_id, r.task_id, r.replicate_index))
|
|
412
|
+
return recovered
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# --- resume config fingerprint ------------------------------------------------
|
|
416
|
+
# The per-task path key (variant_id/task_id/NN) does NOT encode result-affecting
|
|
417
|
+
# run config like the model or backend. So --resume, which matches finalized tasks
|
|
418
|
+
# purely by that path, would otherwise fold results produced under a *different*
|
|
419
|
+
# config into the new run (e.g. resuming a Sonnet run with --model opus keeps the
|
|
420
|
+
# Sonnet results for already-finalized tasks). We stamp the config on every run and
|
|
421
|
+
# warn (don't refuse) when a resume's config differs (in _run_with_experiment) so
|
|
422
|
+
# the resulting mixed-config run.json is surfaced rather than silent.
|
|
423
|
+
RESUME_FINGERPRINT_FILE = "resume_fingerprint.json"
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def compute_run_fingerprint(
|
|
427
|
+
config: BatchRunConfig,
|
|
428
|
+
experiment_id: str,
|
|
429
|
+
backend: str,
|
|
430
|
+
bedrock_model: str | None,
|
|
431
|
+
) -> dict[str, object]:
|
|
432
|
+
"""Snapshot the run config for the --resume drift warning.
|
|
433
|
+
|
|
434
|
+
Dumps the whole config plus the model-selection context that lives outside it.
|
|
435
|
+
Best-effort and informational only — any difference on resume produces a
|
|
436
|
+
warning (resume always proceeds), so this is intentionally not exhaustive about
|
|
437
|
+
which keys are "result-affecting". A benign diff (e.g. --max-parallel) just
|
|
438
|
+
warns harmlessly. mode="json" yields JSON-comparable scalars that match what is
|
|
439
|
+
written to / read back from disk.
|
|
440
|
+
"""
|
|
441
|
+
return config.model_dump(mode="json") | {
|
|
442
|
+
"experiment_id": experiment_id,
|
|
443
|
+
"backend": backend,
|
|
444
|
+
"bedrock_model": bedrock_model,
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def write_run_fingerprint(run_dir: Path, fingerprint: dict[str, object]) -> None:
|
|
449
|
+
"""Stamp the run config at the run root for a future --resume to validate against."""
|
|
450
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
451
|
+
(run_dir / RESUME_FINGERPRINT_FILE).write_text(json.dumps(fingerprint, indent=2, sort_keys=True), encoding="utf-8")
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def read_run_fingerprint(run_dir: Path) -> dict[str, object] | None:
|
|
455
|
+
"""Load a prior run's config stamp, or None if absent/unreadable (e.g. a pre-feature run).
|
|
456
|
+
|
|
457
|
+
A stamp that parses to a non-object (a bare number/string/list from external
|
|
458
|
+
corruption) is folded into the tolerated missing-stamp path rather than reaching
|
|
459
|
+
fingerprint_diff, where a non-dict would raise TypeError or silently no-op the guard.
|
|
460
|
+
"""
|
|
461
|
+
try:
|
|
462
|
+
data = json.loads((run_dir / RESUME_FINGERPRINT_FILE).read_text(encoding="utf-8"))
|
|
463
|
+
except (OSError, json.JSONDecodeError):
|
|
464
|
+
return None
|
|
465
|
+
return data if isinstance(data, dict) else None
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def fingerprint_diff(prior: dict[str, object], current: dict[str, object]) -> dict[str, tuple[object, object]]:
|
|
469
|
+
"""Keys present in BOTH stamps that disagree, as ``{key: (prior, current)}``.
|
|
470
|
+
|
|
471
|
+
Only keys present in ``prior`` are compared, so adding fingerprint fields in a
|
|
472
|
+
later version never false-flags a resume of an older run.
|
|
473
|
+
"""
|
|
474
|
+
return {k: (prior[k], current[k]) for k in current if k in prior and prior[k] != current[k]}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _override_uip_versions_from_tasks(version_info: dict[str, Any], task_results: list[TaskResult]) -> None:
|
|
478
|
+
"""Replace host-captured uip versions with the per-task (container) truth.
|
|
479
|
+
|
|
480
|
+
``cli_version`` and ``tool_plugins`` in ``version_info`` were resolved on
|
|
481
|
+
the machine running this process; the tasks ran inside containers that
|
|
482
|
+
auto-install the latest alpha tool plugins on first use, so only the
|
|
483
|
+
per-task ``environment_info`` (captured in-container, post-task) describes
|
|
484
|
+
what actually executed. Mutates ``version_info`` in place: each key is
|
|
485
|
+
overridden by the consensus across tasks; when tasks disagree (e.g. an
|
|
486
|
+
alpha published mid-run) the sorted distinct versions are joined with
|
|
487
|
+
``" | "`` and a warning is logged. Host values are kept only when no task
|
|
488
|
+
reported one — for ``cli_version`` that means no value besides ``""`` /
|
|
489
|
+
``"unknown"``, and for ``tool_plugins`` no non-empty plugin entry at all
|
|
490
|
+
(legacy results, or every task errored before env capture).
|
|
491
|
+
"""
|
|
492
|
+
# Defence-in-depth alongside the chokepoint fix in _uip_version: filter to
|
|
493
|
+
# version-shaped strings so junk already on disk (older runs captured a
|
|
494
|
+
# CLI that printed a JSON envelope instead of a version) can't leak into
|
|
495
|
+
# the aggregated chip when those results are re-summarised on --resume.
|
|
496
|
+
cli_versions = sorted(
|
|
497
|
+
{
|
|
498
|
+
v
|
|
499
|
+
for r in task_results
|
|
500
|
+
if (env := r.result.environment_info) and looks_like_version(v := env.get("cli_version"))
|
|
501
|
+
}
|
|
502
|
+
)
|
|
503
|
+
if cli_versions:
|
|
504
|
+
if len(cli_versions) > 1:
|
|
505
|
+
logger.warning("cli_version drifted across task containers: %s", cli_versions)
|
|
506
|
+
version_info["cli_version"] = " | ".join(cli_versions)
|
|
507
|
+
|
|
508
|
+
plugin_versions: dict[str, set[str]] = {}
|
|
509
|
+
for r in task_results:
|
|
510
|
+
plugins = (r.result.environment_info or {}).get("tool_plugins")
|
|
511
|
+
if not isinstance(plugins, dict):
|
|
512
|
+
continue
|
|
513
|
+
for name, plugin_version in plugins.items():
|
|
514
|
+
if isinstance(plugin_version, str) and plugin_version:
|
|
515
|
+
plugin_versions.setdefault(name, set()).add(plugin_version)
|
|
516
|
+
# Gate on collected entries, not on "some task had a tool_plugins dict":
|
|
517
|
+
# an all-empty consensus ({} from every task) must not stomp the host
|
|
518
|
+
# fallback — symmetric with the ""/"unknown" filter on cli_version above.
|
|
519
|
+
if plugin_versions:
|
|
520
|
+
drifted = {name: sorted(versions) for name, versions in plugin_versions.items() if len(versions) > 1}
|
|
521
|
+
if drifted:
|
|
522
|
+
logger.warning("tool_plugins drifted across task containers: %s", drifted)
|
|
523
|
+
version_info["tool_plugins"] = {
|
|
524
|
+
name: " | ".join(sorted(versions)) for name, versions in sorted(plugin_versions.items())
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _generate_run_summary(
|
|
529
|
+
run_dir: Path,
|
|
530
|
+
task_results: list[TaskResult],
|
|
531
|
+
start_time: datetime,
|
|
532
|
+
end_time: datetime,
|
|
533
|
+
task_tags: dict[str, list[str]] | None = None,
|
|
534
|
+
*,
|
|
535
|
+
task_paths: dict[str, str] | None = None,
|
|
536
|
+
max_parallel: int = 1,
|
|
537
|
+
skipped_tasks: list[SkippedTask] | None = None,
|
|
538
|
+
) -> RunSummary:
|
|
539
|
+
"""Generate run-level summary from batch results.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
run_dir: Run directory path.
|
|
543
|
+
task_results: List of typed task results.
|
|
544
|
+
start_time: Batch start time.
|
|
545
|
+
end_time: Batch end time.
|
|
546
|
+
task_tags: Optional mapping of task_id -> tags.
|
|
547
|
+
task_paths: Optional mapping of task_id -> source YAML path (string).
|
|
548
|
+
skipped_tasks: Task YAMLs that failed to load upstream.
|
|
549
|
+
|
|
550
|
+
Returns:
|
|
551
|
+
RunSummary with aggregated statistics.
|
|
552
|
+
"""
|
|
553
|
+
summary = build_run_summary(
|
|
554
|
+
run_dir.name,
|
|
555
|
+
task_results,
|
|
556
|
+
start_time,
|
|
557
|
+
end_time,
|
|
558
|
+
task_tags,
|
|
559
|
+
task_paths=task_paths,
|
|
560
|
+
max_parallel=max_parallel,
|
|
561
|
+
skipped_tasks=skipped_tasks,
|
|
562
|
+
)
|
|
563
|
+
write_run_summary(summary, run_dir)
|
|
564
|
+
return summary
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def build_run_summary(
|
|
568
|
+
run_id: str,
|
|
569
|
+
task_results: list[TaskResult],
|
|
570
|
+
start_time: datetime,
|
|
571
|
+
end_time: datetime,
|
|
572
|
+
task_tags: dict[str, list[str]] | None = None,
|
|
573
|
+
*,
|
|
574
|
+
task_paths: dict[str, str] | None = None,
|
|
575
|
+
max_parallel: int = 1,
|
|
576
|
+
skipped_tasks: list[SkippedTask] | None = None,
|
|
577
|
+
) -> RunSummary:
|
|
578
|
+
"""Aggregate task results into a ``RunSummary`` — pure, no disk I/O.
|
|
579
|
+
|
|
580
|
+
The run-summary builder, decoupled from execution so a run can be summarised
|
|
581
|
+
from any source of ``TaskResult``s: a live batch (``run_batch``), results
|
|
582
|
+
recovered from finalized ``task.json`` files on disk (``recover_task_results``),
|
|
583
|
+
or a combination of the two (e.g. splicing the slices of a split run). Pairs
|
|
584
|
+
with ``write_run_summary`` for the persist half.
|
|
585
|
+
|
|
586
|
+
Status buckets use the canonical ``FinalStatus.category`` mapping and the version
|
|
587
|
+
chip is reconciled from the per-task (in-container) captures, so callers never
|
|
588
|
+
re-implement either — the single source of truth for run-level aggregation.
|
|
589
|
+
"""
|
|
590
|
+
statuses = [r.result.final_status for r in task_results]
|
|
591
|
+
|
|
592
|
+
version_info = get_version_info()
|
|
593
|
+
# The run-level cli/tool versions must describe what the tasks executed,
|
|
594
|
+
# not this process's host installs: under --driver docker each task runs
|
|
595
|
+
# in its own container, which auto-installs the latest alpha tool plugins
|
|
596
|
+
# at first use — the host's `uip` tree can differ arbitrarily (#366
|
|
597
|
+
# recorded host values by mistake). Aggregate the per-task (in-container)
|
|
598
|
+
# captures; host values survive only as a fallback when no task reported.
|
|
599
|
+
_override_uip_versions_from_tasks(version_info, task_results)
|
|
600
|
+
host_coder_eval = version_info.get("coder_eval", "unknown")
|
|
601
|
+
# Surface host↔container version drift: under --driver docker the agent
|
|
602
|
+
# ran against the image's version, not the host's. Without this warning
|
|
603
|
+
# framework_version silently mis-attributes the runtime.
|
|
604
|
+
container_versions = {
|
|
605
|
+
(r.result.environment_info or {}).get("coder_eval") for r in task_results if r.result.environment_info
|
|
606
|
+
}
|
|
607
|
+
container_versions.discard(None)
|
|
608
|
+
container_versions.discard(host_coder_eval)
|
|
609
|
+
if container_versions:
|
|
610
|
+
logger.warning(
|
|
611
|
+
"Container coder_eval %s != host %s; framework_version is host. Per-task versions in task.json.",
|
|
612
|
+
sorted(v for v in container_versions if v),
|
|
613
|
+
host_coder_eval,
|
|
614
|
+
)
|
|
615
|
+
return RunSummary(
|
|
616
|
+
run_id=run_id,
|
|
617
|
+
start_time=start_time,
|
|
618
|
+
end_time=end_time,
|
|
619
|
+
total_duration_seconds=(end_time - start_time).total_seconds(),
|
|
620
|
+
tasks_run=len(task_results),
|
|
621
|
+
tasks_succeeded=sum(1 for s in statuses if s.category == "succeeded"),
|
|
622
|
+
tasks_failed=sum(1 for s in statuses if s.category == "failed"),
|
|
623
|
+
tasks_error=sum(1 for s in statuses if s.category == "error"),
|
|
624
|
+
tasks_token_budget_exceeded=sum(1 for s in statuses if s == FinalStatus.TOKEN_BUDGET_EXCEEDED),
|
|
625
|
+
tasks_cost_budget_exceeded=sum(1 for s in statuses if s == FinalStatus.COST_BUDGET_EXCEEDED),
|
|
626
|
+
skipped_tasks=skipped_tasks or [],
|
|
627
|
+
max_parallel=max_parallel,
|
|
628
|
+
task_results=[
|
|
629
|
+
eval_result_to_task_dict(
|
|
630
|
+
r.result,
|
|
631
|
+
variant_id=r.variant_id,
|
|
632
|
+
tags=(task_tags or {}).get(r.task_id, []),
|
|
633
|
+
task_path=(task_paths or {}).get(r.task_id),
|
|
634
|
+
duration_override=r.duration,
|
|
635
|
+
replicate_index=r.replicate_index,
|
|
636
|
+
)
|
|
637
|
+
for r in task_results
|
|
638
|
+
],
|
|
639
|
+
framework_version=version_info.get("coder_eval", "unknown"),
|
|
640
|
+
environment_info=version_info,
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def write_run_summary(summary: RunSummary, run_dir: Path) -> None:
|
|
645
|
+
"""Persist a ``RunSummary`` to ``run_dir``: ``run.json`` + the ``run.md`` report.
|
|
646
|
+
|
|
647
|
+
The persist half of the run-summary seam (see ``build_run_summary``). ``run.md``
|
|
648
|
+
carries command statistics rendered from the per-task data under ``run_dir``.
|
|
649
|
+
"""
|
|
650
|
+
from ..reports import ReportGenerator
|
|
651
|
+
|
|
652
|
+
# Create run directory first to eliminate race condition
|
|
653
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
654
|
+
|
|
655
|
+
# run.json — run-level summary (distinct from experiment.json from ExperimentReportGenerator)
|
|
656
|
+
(run_dir / "run.json").write_text(summary.model_dump_json(indent=2), encoding="utf-8")
|
|
657
|
+
|
|
658
|
+
# run.md — command statistics
|
|
659
|
+
report_md = ReportGenerator.generate_markdown(summary, run_dir=run_dir)
|
|
660
|
+
(run_dir / "run.md").write_text(report_md, encoding="utf-8")
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def filter_tasks_by_tags(
|
|
664
|
+
tasks: list[tuple[Path, TaskDefinition]],
|
|
665
|
+
include_tags: set[str] | None = None,
|
|
666
|
+
exclude_tags: set[str] | None = None,
|
|
667
|
+
) -> list[tuple[Path, TaskDefinition]]:
|
|
668
|
+
"""Filter tasks by tag inclusion/exclusion (OR logic).
|
|
669
|
+
|
|
670
|
+
Args:
|
|
671
|
+
tasks: List of (task_file, task_definition) tuples.
|
|
672
|
+
include_tags: If set, only keep tasks matching ANY of these tags.
|
|
673
|
+
exclude_tags: If set, remove tasks matching ANY of these tags.
|
|
674
|
+
|
|
675
|
+
Returns:
|
|
676
|
+
Filtered list of (task_file, task_definition) tuples.
|
|
677
|
+
"""
|
|
678
|
+
result = tasks
|
|
679
|
+
if include_tags:
|
|
680
|
+
result = [(p, t) for p, t in result if include_tags & set(t.tags)]
|
|
681
|
+
skipped = len(tasks) - len(result)
|
|
682
|
+
if skipped:
|
|
683
|
+
logger.info("Tag filter: included %d/%d tasks (tags: %s)", len(result), len(tasks), ", ".join(include_tags))
|
|
684
|
+
if exclude_tags:
|
|
685
|
+
before = len(result)
|
|
686
|
+
result = [(p, t) for p, t in result if not (exclude_tags & set(t.tags))]
|
|
687
|
+
skipped = before - len(result)
|
|
688
|
+
if skipped:
|
|
689
|
+
logger.info("Tag filter: excluded %d tasks (tags: %s)", skipped, ", ".join(exclude_tags))
|
|
690
|
+
return result
|