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.
Files changed (127) hide show
  1. coder_eval/.gitattributes +2 -0
  2. coder_eval/__init__.py +3 -0
  3. coder_eval/agent.py +302 -0
  4. coder_eval/agents/__init__.py +41 -0
  5. coder_eval/agents/_logging.py +89 -0
  6. coder_eval/agents/antigravity_agent.py +811 -0
  7. coder_eval/agents/claude_code_agent.py +1701 -0
  8. coder_eval/agents/codex_agent.py +2055 -0
  9. coder_eval/agents/noop_agent.py +100 -0
  10. coder_eval/agents/registry.py +163 -0
  11. coder_eval/agents/watchdog.py +116 -0
  12. coder_eval/analysis.py +88 -0
  13. coder_eval/cli/__init__.py +87 -0
  14. coder_eval/cli/aggregate_command.py +153 -0
  15. coder_eval/cli/console.py +7 -0
  16. coder_eval/cli/evaluate_command.py +164 -0
  17. coder_eval/cli/plan_command.py +152 -0
  18. coder_eval/cli/report_command.py +121 -0
  19. coder_eval/cli/run_command.py +686 -0
  20. coder_eval/cli/run_helpers.py +137 -0
  21. coder_eval/cli/run_task_internal_command.py +210 -0
  22. coder_eval/cli/utils.py +37 -0
  23. coder_eval/config.py +212 -0
  24. coder_eval/criteria/__init__.py +163 -0
  25. coder_eval/criteria/_classification_aggregate.py +106 -0
  26. coder_eval/criteria/agent_judge.py +425 -0
  27. coder_eval/criteria/base.py +248 -0
  28. coder_eval/criteria/classification_match.py +107 -0
  29. coder_eval/criteria/command_executed.py +181 -0
  30. coder_eval/criteria/commands_efficiency.py +73 -0
  31. coder_eval/criteria/file_check.py +119 -0
  32. coder_eval/criteria/file_contains.py +85 -0
  33. coder_eval/criteria/file_exists.py +45 -0
  34. coder_eval/criteria/file_matches_regex.py +86 -0
  35. coder_eval/criteria/json_check.py +184 -0
  36. coder_eval/criteria/llm_judge.py +260 -0
  37. coder_eval/criteria/reference_comparison.py +130 -0
  38. coder_eval/criteria/run_command.py +220 -0
  39. coder_eval/criteria/skill_triggered.py +111 -0
  40. coder_eval/criteria/uipath_eval.py +223 -0
  41. coder_eval/errors/__init__.py +26 -0
  42. coder_eval/errors/agent.py +26 -0
  43. coder_eval/errors/budget.py +28 -0
  44. coder_eval/errors/categories.py +205 -0
  45. coder_eval/errors/categorization.py +240 -0
  46. coder_eval/errors/executor.py +124 -0
  47. coder_eval/errors/judge.py +12 -0
  48. coder_eval/errors/retry.py +211 -0
  49. coder_eval/errors/timeout.py +63 -0
  50. coder_eval/evaluation/__init__.py +10 -0
  51. coder_eval/evaluation/checker.py +260 -0
  52. coder_eval/evaluation/judge_anthropic.py +55 -0
  53. coder_eval/evaluation/judge_bedrock.py +114 -0
  54. coder_eval/evaluation/judge_context.py +497 -0
  55. coder_eval/evaluation/judge_models.py +53 -0
  56. coder_eval/evaluation/judge_persistence.py +291 -0
  57. coder_eval/evaluation/judge_usage.py +50 -0
  58. coder_eval/evaluation/sub_agent.py +231 -0
  59. coder_eval/evaluation/summaries.py +48 -0
  60. coder_eval/evaluation/verdict_tool.py +213 -0
  61. coder_eval/formatting.py +191 -0
  62. coder_eval/isolation/__init__.py +15 -0
  63. coder_eval/isolation/docker_runner.py +1253 -0
  64. coder_eval/logging_config.py +399 -0
  65. coder_eval/models/__init__.py +337 -0
  66. coder_eval/models/agent_config.py +373 -0
  67. coder_eval/models/container_paths.py +26 -0
  68. coder_eval/models/criteria.py +942 -0
  69. coder_eval/models/enums.py +121 -0
  70. coder_eval/models/experiment.py +314 -0
  71. coder_eval/models/judge.py +90 -0
  72. coder_eval/models/judge_defaults.py +11 -0
  73. coder_eval/models/limits.py +89 -0
  74. coder_eval/models/merge_strategy.py +132 -0
  75. coder_eval/models/mutations.py +95 -0
  76. coder_eval/models/results.py +787 -0
  77. coder_eval/models/routing.py +160 -0
  78. coder_eval/models/sandbox.py +371 -0
  79. coder_eval/models/tasks.py +631 -0
  80. coder_eval/models/telemetry.py +454 -0
  81. coder_eval/models/templates.py +108 -0
  82. coder_eval/orchestration/__init__.py +13 -0
  83. coder_eval/orchestration/batch.py +690 -0
  84. coder_eval/orchestration/config.py +111 -0
  85. coder_eval/orchestration/config_merge.py +431 -0
  86. coder_eval/orchestration/evaluation.py +94 -0
  87. coder_eval/orchestration/experiment.py +837 -0
  88. coder_eval/orchestration/overrides.py +206 -0
  89. coder_eval/orchestration/task_loader.py +437 -0
  90. coder_eval/orchestrator.py +2078 -0
  91. coder_eval/path_utils.py +79 -0
  92. coder_eval/plugins.py +81 -0
  93. coder_eval/pricing.py +169 -0
  94. coder_eval/py.typed +0 -0
  95. coder_eval/reports.py +1066 -0
  96. coder_eval/reports_experiment.py +761 -0
  97. coder_eval/reports_html.py +1659 -0
  98. coder_eval/reports_stats.py +250 -0
  99. coder_eval/resources/__init__.py +137 -0
  100. coder_eval/resources/default_experiment.yaml +85 -0
  101. coder_eval/resources/default_ignore_patterns.yaml +60 -0
  102. coder_eval/resources/tags.yaml +55 -0
  103. coder_eval/sandbox.py +1127 -0
  104. coder_eval/scoring/__init__.py +11 -0
  105. coder_eval/scoring/ast_similarity.py +38 -0
  106. coder_eval/scoring/complexity.py +115 -0
  107. coder_eval/scoring/quality.py +154 -0
  108. coder_eval/scoring/signature_similarity.py +57 -0
  109. coder_eval/scoring/similarity.py +103 -0
  110. coder_eval/scoring/token_similarity.py +44 -0
  111. coder_eval/simulation/__init__.py +27 -0
  112. coder_eval/simulation/termination.py +88 -0
  113. coder_eval/simulation/user_simulator.py +324 -0
  114. coder_eval/streaming/__init__.py +44 -0
  115. coder_eval/streaming/callbacks.py +57 -0
  116. coder_eval/streaming/collector.py +193 -0
  117. coder_eval/streaming/events.py +198 -0
  118. coder_eval/streaming/renderers.py +248 -0
  119. coder_eval/streaming/wire.py +115 -0
  120. coder_eval/telemetry.py +407 -0
  121. coder_eval/utils.py +517 -0
  122. coder_eval-0.8.2.dist-info/METADATA +242 -0
  123. coder_eval-0.8.2.dist-info/RECORD +127 -0
  124. coder_eval-0.8.2.dist-info/WHEEL +4 -0
  125. coder_eval-0.8.2.dist-info/entry_points.txt +5 -0
  126. coder_eval-0.8.2.dist-info/licenses/LICENSE +201 -0
  127. 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