pluginbench 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. pluginbench/__init__.py +3 -0
  2. pluginbench/__main__.py +3 -0
  3. pluginbench/assessment-charts.html +790 -0
  4. pluginbench/assessment.py +316 -0
  5. pluginbench/assessment_charts.py +1225 -0
  6. pluginbench/assessment_metrics.py +1956 -0
  7. pluginbench/assessment_models.py +127 -0
  8. pluginbench/baselines.py +792 -0
  9. pluginbench/claude.py +374 -0
  10. pluginbench/cli.py +672 -0
  11. pluginbench/code_diff.py +207 -0
  12. pluginbench/codex.py +451 -0
  13. pluginbench/codex_events.py +585 -0
  14. pluginbench/collectors.py +591 -0
  15. pluginbench/compare.py +927 -0
  16. pluginbench/components.py +129 -0
  17. pluginbench/coverage_report.py +610 -0
  18. pluginbench/datasets.py +207 -0
  19. pluginbench/evaluation.example.yaml +45 -0
  20. pluginbench/evaluation.py +1651 -0
  21. pluginbench/evaluation_report.py +1004 -0
  22. pluginbench/fallow_parse.mjs +101 -0
  23. pluginbench/harbor_backend.py +2946 -0
  24. pluginbench/isolation.py +314 -0
  25. pluginbench/judge.py +683 -0
  26. pluginbench/language_analysis.py +915 -0
  27. pluginbench/ledger.py +217 -0
  28. pluginbench/measurement_profiles.py +616 -0
  29. pluginbench/native_treatment_runtime.js +221 -0
  30. pluginbench/native_treatments.py +155 -0
  31. pluginbench/observer_data/go-analysis-v1/go.mod +10 -0
  32. pluginbench/observer_data/go-analysis-v1/go.sum +10 -0
  33. pluginbench/observer_data/go-analysis-v1/main.go +64 -0
  34. pluginbench/observer_data/oxc-parser-0.126.0/package-lock.json +449 -0
  35. pluginbench/observer_data/oxc-parser-0.126.0/package.json +5 -0
  36. pluginbench/parallel.py +101 -0
  37. pluginbench/processes.py +100 -0
  38. pluginbench/remeasure.py +992 -0
  39. pluginbench/run_lock.py +227 -0
  40. pluginbench/run_status.py +518 -0
  41. pluginbench/study.py +1858 -0
  42. pluginbench/study_review.py +924 -0
  43. pluginbench/submissions.py +486 -0
  44. pluginbench/task_sources.py +403 -0
  45. pluginbench/treatments.py +388 -0
  46. pluginbench/trial_containers.py +94 -0
  47. pluginbench/vendor/ECHARTS-LICENSE +222 -0
  48. pluginbench/vendor/ECHARTS-NOTICE +5 -0
  49. pluginbench/vendor/ZRENDER-LICENSE +29 -0
  50. pluginbench/vendor/echarts.min.js +48 -0
  51. pluginbench-0.5.0.dist-info/METADATA +294 -0
  52. pluginbench-0.5.0.dist-info/RECORD +58 -0
  53. pluginbench-0.5.0.dist-info/WHEEL +4 -0
  54. pluginbench-0.5.0.dist-info/entry_points.txt +2 -0
  55. pluginbench-0.5.0.dist-info/licenses/LICENSE +201 -0
  56. pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-LICENSE +222 -0
  57. pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-NOTICE +5 -0
  58. pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ZRENDER-LICENSE +29 -0
@@ -0,0 +1,388 @@
1
+ """Resolve and freeze native instruction files, skill trees, and plugin packages.
2
+
3
+ This validates package contents, not whether a particular runtime can load them.
4
+ Git internals are excluded from snapshots. Ambient dotenv files are rejected;
5
+ only explicitly named example/sample/template dotenv files are allowed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import re
14
+ import shutil
15
+ import stat
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import yaml
20
+ from pydantic import BaseModel, ConfigDict, Field
21
+
22
+
23
+ class Treatment(BaseModel):
24
+ model_config = ConfigDict(extra="forbid")
25
+
26
+ guidance: list[Path] = Field(default_factory=list)
27
+ skills: list[Path] = Field(default_factory=list)
28
+ plugins: list[Path] = Field(default_factory=list)
29
+
30
+
31
+ def _checked_path(path: Path, base: Path) -> Path:
32
+ """Check components before normalization, so symlink/../ cannot hide a link."""
33
+ value = path.expanduser()
34
+ if not value.is_absolute():
35
+ value = base.absolute() / value
36
+ if ".git" in value.parts:
37
+ raise ValueError(f"treatment must not select Git internals: {value}")
38
+ current = Path(value.anchor)
39
+ for part in value.parts[1:]:
40
+ if part == "..":
41
+ current = current.parent
42
+ continue
43
+ current = current / part
44
+ try:
45
+ mode = current.lstat().st_mode
46
+ except OSError as error:
47
+ raise ValueError(f"treatment path is missing or unreadable: {value}") from error
48
+ if stat.S_ISLNK(mode):
49
+ raise ValueError(f"treatment path must not contain a symlink: {current}")
50
+ return current
51
+
52
+
53
+ def _tree(root: Path) -> list[Path]:
54
+ _checked_path(root, Path.cwd())
55
+ paths = [root]
56
+ if not root.is_dir():
57
+ raise ValueError(f"treatment package must be a directory: {root}")
58
+ def unreadable(error: OSError) -> None:
59
+ raise ValueError(f"treatment contains an unreadable directory: {error.filename}") from error
60
+
61
+ for directory, dirs, files in os.walk(root, followlinks=False, onerror=unreadable):
62
+ parent = Path(directory)
63
+ for name in sorted([*dirs, *files]):
64
+ path = parent / name
65
+ mode = path.lstat().st_mode
66
+ if stat.S_ISLNK(mode):
67
+ raise ValueError(f"treatment contains a symlink: {path}")
68
+ if not (stat.S_ISDIR(mode) or stat.S_ISREG(mode)):
69
+ raise ValueError(f"treatment contains a nonregular asset: {path}")
70
+ if name == ".git":
71
+ continue
72
+ if (name == ".env" or name.startswith(".env.")) and name not in {
73
+ ".env.example", ".env.sample", ".env.template"
74
+ }:
75
+ raise ValueError(f"treatment contains an ambient dotenv file: {path}")
76
+ paths.append(path)
77
+ dirs[:] = sorted(name for name in dirs if name != ".git")
78
+ return sorted(paths, key=lambda path: path.relative_to(root).as_posix())
79
+
80
+
81
+ def _guidance(path: Path, base: Path) -> Path:
82
+ path = _checked_path(path, base)
83
+ if not stat.S_ISREG(path.stat().st_mode) or path.suffix.lower() not in {".md", ".txt"}:
84
+ raise ValueError(f"guidance must select regular .md/.txt files: {path}")
85
+ try:
86
+ path.read_text(encoding="utf-8")
87
+ except UnicodeError as error:
88
+ raise ValueError(f"guidance must be UTF-8: {path}") from error
89
+ return path
90
+
91
+
92
+ def _skill_name(root: Path) -> str:
93
+ skill = root / "SKILL.md"
94
+ try:
95
+ text = skill.read_text(encoding="utf-8")
96
+ except (OSError, UnicodeError) as error:
97
+ raise ValueError(f"skill requires a UTF-8 SKILL.md: {root}") from error
98
+ lines = text.splitlines()
99
+ if not lines or lines[0] != "---" or "---" not in lines[1:]:
100
+ raise ValueError(f"skill requires YAML frontmatter: {skill}")
101
+ try:
102
+ metadata = yaml.safe_load("\n".join(lines[1 : lines.index("---", 1)]))
103
+ except yaml.YAMLError as error:
104
+ raise ValueError(f"invalid skill frontmatter: {skill}") from error
105
+ if not isinstance(metadata, dict):
106
+ raise ValueError(f"skill frontmatter must be an object: {skill}")
107
+ name = metadata.get("name")
108
+ if not isinstance(name, str) or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name):
109
+ raise ValueError(f"skill requires a kebab-case name: {skill}")
110
+ description = metadata.get("description")
111
+ if not isinstance(description, str) or not description.strip():
112
+ raise ValueError(f"skill requires a description: {skill}")
113
+ _skill_dependencies(metadata, skill)
114
+ agent_file = root / "agents/openai.yaml"
115
+ if agent_file.exists():
116
+ try:
117
+ agent = yaml.safe_load(agent_file.read_text(encoding="utf-8"))
118
+ except (OSError, UnicodeError, yaml.YAMLError) as error:
119
+ raise ValueError(f"invalid skill agent metadata: {agent_file}") from error
120
+ if not isinstance(agent, dict):
121
+ raise ValueError(f"skill agent metadata must be an object: {agent_file}")
122
+ _skill_dependencies(agent, agent_file)
123
+ if (root / "SKILL.json").exists():
124
+ _skill_dependencies(_json_object(root / "SKILL.json"), root / "SKILL.json")
125
+ return name
126
+
127
+
128
+ def _skill_dependencies(metadata: dict[str, Any], source: Path) -> None:
129
+ dependencies = metadata.get("dependencies", {})
130
+ if (not isinstance(dependencies, dict)
131
+ or any(dependencies.values())):
132
+ raise ValueError(f"unsupported declared skill dependencies: {source}")
133
+
134
+
135
+ def _skill_roots(path: Path) -> list[Path]:
136
+ _tree(path)
137
+ roots = [path] if (path / "SKILL.md").exists() else [
138
+ child for child in sorted(path.iterdir())
139
+ if child.is_dir() and child.name != ".git" and (child / "SKILL.md").exists()
140
+ ]
141
+ if not roots:
142
+ raise ValueError(f"skill directory contains no direct SKILL.md roots: {path}")
143
+ names = [_skill_name(root) for root in roots]
144
+ if len(set(names)) != len(names):
145
+ raise ValueError(f"duplicate skill names in collection: {path}")
146
+ return roots
147
+
148
+
149
+ def _json_object(path: Path) -> dict[str, Any]:
150
+ try:
151
+ value = json.loads(path.read_text(encoding="utf-8"))
152
+ except (OSError, UnicodeError, json.JSONDecodeError) as error:
153
+ raise ValueError(f"invalid or missing plugin JSON: {path}") from error
154
+ if not isinstance(value, dict):
155
+ raise ValueError(f"plugin JSON must be an object: {path}")
156
+ return value
157
+
158
+
159
+ def _component(root: Path, value: object) -> Path:
160
+ if not isinstance(value, str) or not value.startswith("./"):
161
+ raise ValueError(f"plugin component path must start with ./ inside {root}: {value}")
162
+ relative = Path(value)
163
+ if ".." in relative.parts:
164
+ raise ValueError(f"plugin component path escapes its package: {value}")
165
+ path = _checked_path(relative, root)
166
+ if ".git" in path.relative_to(root).parts:
167
+ raise ValueError(f"plugin component must not refer to excluded Git metadata: {value}")
168
+ return path
169
+
170
+
171
+ def _hooks(root: Path, value: object) -> list[dict[str, Any]]:
172
+ values = value if isinstance(value, list) else [value]
173
+ configs = []
174
+ for entry in values:
175
+ config = _json_object(_component(root, entry)) if isinstance(entry, str) else entry
176
+ if not isinstance(config, dict):
177
+ raise ValueError(f"plugin hooks must be JSON paths or objects: {root}")
178
+ if config and "hooks" not in config:
179
+ raise ValueError(f"plugin hooks require the full hook-file object with hooks: {root}")
180
+ events = config.get("hooks", {})
181
+ if not isinstance(events, dict):
182
+ raise ValueError(f"plugin hook events must be an object: {root}")
183
+ for groups in events.values():
184
+ if not isinstance(groups, list):
185
+ raise ValueError(f"plugin hook event must contain a list: {root}")
186
+ for group in groups:
187
+ if not isinstance(group, dict) or not isinstance(group.get("hooks"), list):
188
+ raise ValueError(f"invalid plugin hook group: {root}")
189
+ for hook in group["hooks"]:
190
+ if (not isinstance(hook, dict) or hook.get("type") != "command"
191
+ or not isinstance(hook.get("command"), str)
192
+ or not hook["command"].strip()):
193
+ raise ValueError(f"only command plugin hooks are supported: {root}")
194
+ if hook.get("async") or hook.get("asyncRewake"):
195
+ raise ValueError(f"asynchronous plugin hooks are unsupported: {root}")
196
+ if any(events.values()):
197
+ configs.append(config)
198
+ return configs
199
+
200
+
201
+ _METADATA = {
202
+ "$schema", "name", "version", "description", "author", "homepage", "repository",
203
+ "license", "keywords", "interface", "extensions", "onboardingSkill",
204
+ }
205
+ _COMPONENTS = {"skills", "hooks", "mcpServers", "apps", "agents", "commands", "dependencies"}
206
+
207
+
208
+ def _plugin(root: Path, harness: str | None = None) -> dict[str, Any]:
209
+ _tree(root)
210
+ portable = root / "plugin.json"
211
+ codex = root / ".codex-plugin/plugin.json"
212
+ claude = root / ".claude-plugin/plugin.json"
213
+ manifest_path = portable if portable.exists() else (
214
+ claude if harness == "claude" else codex
215
+ )
216
+ if harness is None and not manifest_path.exists() and claude.exists():
217
+ manifest_path = claude
218
+ manifest = _json_object(manifest_path)
219
+ name = manifest.get("name")
220
+ if not isinstance(name, str) or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name):
221
+ raise ValueError(f"plugin requires a kebab-case name: {manifest_path}")
222
+ unknown = set(manifest) - _METADATA - _COMPONENTS
223
+ if unknown:
224
+ raise ValueError(f"unsupported plugin manifest fields: {sorted(unknown)}")
225
+ settings = manifest
226
+ if manifest_path == portable:
227
+ extensions = manifest.get("extensions", {})
228
+ if not isinstance(extensions, dict):
229
+ raise ValueError(f"plugin extensions must be an object: {manifest_path}")
230
+ unknown_extensions = set(extensions) - {"com.openai"}
231
+ if unknown_extensions:
232
+ raise ValueError(f"unsupported plugin extensions: {sorted(unknown_extensions)}")
233
+ if "com.openai" in extensions:
234
+ settings = extensions["com.openai"]
235
+ if not isinstance(settings, dict):
236
+ raise ValueError(f"plugin com.openai extension must be an object: {manifest_path}")
237
+ elif codex.exists() and harness != "claude":
238
+ settings = _json_object(codex)
239
+ unknown = set(settings) - _METADATA - _COMPONENTS
240
+ if unknown:
241
+ raise ValueError(f"unsupported plugin settings: {sorted(unknown)}")
242
+ onboarding = [settings["onboardingSkill"]] if "onboardingSkill" in settings else []
243
+ if manifest_path != portable and "extensions" in settings:
244
+ extensions = settings["extensions"]
245
+ if not isinstance(extensions, dict) or set(extensions) - {"com.openai"}:
246
+ raise ValueError(f"unsupported plugin compatibility extensions: {manifest_path}")
247
+ extension = extensions.get("com.openai", {})
248
+ if not isinstance(extension, dict) or set(extension) - {"onboardingSkill"}:
249
+ raise ValueError(f"unsupported plugin compatibility extension fields: {manifest_path}")
250
+ if "onboardingSkill" in extension:
251
+ onboarding.append(extension["onboardingSkill"])
252
+ for document in (manifest, settings):
253
+ for field in ("mcpServers", "apps", "agents", "commands", "dependencies"):
254
+ if document.get(field):
255
+ raise ValueError(f"unsupported declared plugin component {field}: {root}")
256
+ # Default MCP files are active components even without a manifest declaration.
257
+ for filename in ("mcp.json", ".mcp.json", ".app.json"):
258
+ if (root / filename).exists():
259
+ config = _json_object(root / filename)
260
+ if config.get("mcpServers") or config.get("apps"):
261
+ raise ValueError(f"unsupported plugin MCP/app configuration: {root / filename}")
262
+ skill_paths = settings.get("skills")
263
+ if skill_paths is None:
264
+ roots = _skill_roots(root / "skills") if (root / "skills").exists() else []
265
+ else:
266
+ paths = skill_paths if isinstance(skill_paths, list) else [skill_paths]
267
+ roots = [skill for value in paths for skill in _skill_roots(_component(root, value))]
268
+ names = [_skill_name(skill) for skill in roots]
269
+ if len(set(names)) != len(names):
270
+ raise ValueError(f"duplicate plugin skill names: {root}")
271
+ if "hooks" in settings:
272
+ hooks = _hooks(root, settings["hooks"])
273
+ elif (root / "hooks/hooks.json").exists():
274
+ hooks = _hooks(root, "./hooks/hooks.json")
275
+ else:
276
+ hooks = []
277
+ for value in onboarding:
278
+ path = _component(root, value)
279
+ if path.name != "SKILL.md" or path.parent not in roots:
280
+ raise ValueError(f"plugin onboardingSkill must reference a packaged skill: {path}")
281
+ interface = settings.get("interface", {})
282
+ if not isinstance(interface, dict):
283
+ raise ValueError(f"plugin interface must be an object: {root}")
284
+ for field in ("composerIcon", "logo", "screenshots"):
285
+ if field in interface:
286
+ values = interface[field] if isinstance(interface[field], list) else [interface[field]]
287
+ for value in values:
288
+ if not _component(root, value).is_file():
289
+ raise ValueError(f"plugin interface asset must be a file: {value}")
290
+ return {
291
+ "name": name,
292
+ "manifest_path": manifest_path.relative_to(root).as_posix(),
293
+ "manifest": manifest,
294
+ "settings": settings,
295
+ "skills": [{"name": _skill_name(skill), "path": skill.relative_to(root).as_posix()}
296
+ for skill in roots],
297
+ "hooks": hooks,
298
+ }
299
+
300
+
301
+ def resolve_treatment(treatment: Treatment, base: Path, harness: str) -> Treatment:
302
+ """Validate selected inputs and return absolute files and actual skill roots."""
303
+ if harness not in {"codex", "claude"}:
304
+ raise ValueError(f"unsupported treatment harness: {harness}")
305
+ if harness == "claude" and treatment.plugins:
306
+ raise ValueError("native plugin qualification currently supports only Codex")
307
+ guidance = [_guidance(path, base) for path in treatment.guidance]
308
+ if len(set(guidance)) != len(guidance):
309
+ raise ValueError("duplicate guidance files in treatment")
310
+ skills = [root for path in treatment.skills
311
+ for root in _skill_roots(_checked_path(path, base))]
312
+ names = [_skill_name(root) for root in skills]
313
+ if len(set(names)) != len(names):
314
+ raise ValueError("duplicate skill names in treatment")
315
+ if len({root.name for root in skills}) != len(skills):
316
+ raise ValueError("duplicate skill directory basenames in treatment")
317
+ plugins = [_checked_path(path, base) for path in treatment.plugins]
318
+ names = [_plugin(root, harness)["name"] for root in plugins]
319
+ if len(set(names)) != len(names):
320
+ raise ValueError("duplicate plugin identity in treatment")
321
+ return Treatment(guidance=guidance, skills=skills, plugins=plugins)
322
+
323
+
324
+ def _entries(root: Path) -> list[dict[str, Any]]:
325
+ entries = []
326
+ for path in _tree(root):
327
+ mode = stat.S_IMODE(path.stat().st_mode)
328
+ entry: dict[str, Any] = {
329
+ "path": path.relative_to(root).as_posix(),
330
+ "kind": "directory" if path.is_dir() else "file", "mode": mode,
331
+ }
332
+ if path.is_file():
333
+ content = path.read_bytes()
334
+ entry.update(sha256=hashlib.sha256(content).hexdigest(), size=len(content))
335
+ entries.append(entry)
336
+ return entries
337
+
338
+
339
+ def _package_inventory(root: Path) -> dict[str, Any]:
340
+ entries = _entries(root)
341
+ digest = hashlib.sha256(json.dumps(entries, sort_keys=True).encode()).hexdigest()
342
+ return {"directory": root.name, "sha256": digest, "entries": entries}
343
+
344
+
345
+ def treatment_inventory(treatment: Treatment) -> dict[str, Any]:
346
+ """Portable content/mode identities, independent of installation paths."""
347
+ guidance = [_guidance(path, Path.cwd()) for path in treatment.guidance]
348
+ return {
349
+ "guidance": [{"name": path.name, "mode": stat.S_IMODE(path.stat().st_mode),
350
+ "sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
351
+ "size": path.stat().st_size} for path in guidance],
352
+ "skills": [{**_package_inventory(root), "name": _skill_name(root)}
353
+ for root in treatment.skills],
354
+ "plugins": [{**_package_inventory(root), **_plugin(root)} for root in treatment.plugins],
355
+ }
356
+
357
+
358
+ def freeze_treatment(treatment: Treatment, destination: Path) -> Treatment:
359
+ """Copy complete packages into deterministic locations without Git internals."""
360
+ # Inventory first: reject invalid packages before writing a partial snapshot.
361
+ before = treatment_inventory(treatment)
362
+ for path in [destination, *destination.parents]:
363
+ if path.exists() or path.is_symlink():
364
+ _checked_path(path, Path.cwd())
365
+ normalized = Path(os.path.abspath(destination))
366
+ if any(normalized.is_relative_to(root.absolute()) for root in [
367
+ *treatment.skills, *treatment.plugins,
368
+ ]):
369
+ raise ValueError("treatment snapshot destination must be outside its input packages")
370
+ if destination.exists() and not destination.is_dir():
371
+ raise ValueError(f"treatment snapshot destination must be a directory: {destination}")
372
+ if destination.exists() and any(destination.iterdir()):
373
+ raise ValueError(f"treatment snapshot destination must be empty: {destination}")
374
+ destination.mkdir(parents=True, exist_ok=True)
375
+ frozen: dict[str, list[Path]] = {"guidance": [], "skills": [], "plugins": []}
376
+ for kind in frozen:
377
+ for index, source in enumerate(getattr(treatment, kind)):
378
+ target = destination / kind / f"{index:03d}" / source.name
379
+ target.parent.mkdir(parents=True, exist_ok=True)
380
+ if kind == "guidance":
381
+ shutil.copy2(source, target)
382
+ else:
383
+ shutil.copytree(source, target, ignore=shutil.ignore_patterns(".git"))
384
+ frozen[kind].append(target.absolute())
385
+ result = Treatment(**frozen)
386
+ if treatment_inventory(result) != before:
387
+ raise ValueError("treatment changed while its snapshot was being frozen")
388
+ return result
@@ -0,0 +1,94 @@
1
+ """Find the containers a run's Harbor trials left running, and stop them; never remove any.
2
+
3
+ A killed run leaves its trial's compose project up: the kill skips the cleanup that removes it.
4
+ Ownership is proven from the run's own files. Harbor starts each trial's compose project with
5
+ `--project-directory` set inside the run's frozen task (evidence/frozen/suite/<i>/task), and
6
+ Docker Compose records that directory as the `com.docker.compose.project.working_dir` label.
7
+ The trial directory itself is no proof: it lives in a temporary area outside the run.
8
+
9
+ Removal stays with harbor_backend.cleanup(); this module only lists and stops.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ from collections.abc import Callable
17
+ from pathlib import Path
18
+
19
+ from pluginbench import harbor_backend
20
+
21
+ _WORKING_DIR = "com.docker.compose.project.working_dir"
22
+ # harbor_backend names each trial `pluginbench-<uuid4 hex>`; Harbor adds `__env` for the agent's
23
+ # project and `__verifier__<key>` for a separate verifier's.
24
+ _TRIAL_PROJECT = re.compile(r"pluginbench-[0-9a-f]{32}__.+")
25
+ _FORMAT = (
26
+ "[{{json .ID}},{{json .Names}},"
27
+ '{{json (.Label "com.docker.compose.project")}},'
28
+ f'{{{{json (.Label "{_WORKING_DIR}")}}}}]'
29
+ )
30
+
31
+
32
+ def running(evidence: Path, *, expected_runtime: dict[str, object] | None) -> list[dict[str, str]]:
33
+ """Running containers of this run's Harbor trials: id, name and compose project.
34
+
35
+ `expected_runtime` pins the Docker CLI and engine, as for every other Docker call of a run;
36
+ None uses the selected Docker context, which is how a coding attempt picks its engine.
37
+ """
38
+ suite = (evidence / "frozen" / "suite").resolve()
39
+ if not suite.is_dir():
40
+ return [] # No task was frozen, so no trial ever started.
41
+ # `docker ps` without --all lists only running containers.
42
+ output = harbor_backend._docker(
43
+ "ps",
44
+ "--no-trunc",
45
+ "--filter",
46
+ "status=running",
47
+ "--filter",
48
+ f"label={_WORKING_DIR}",
49
+ "--format",
50
+ _FORMAT,
51
+ expected_runtime=expected_runtime,
52
+ )
53
+ found = []
54
+ for line in output.splitlines():
55
+ if not line.strip():
56
+ continue
57
+ container, name, project, working_dir = json.loads(line)
58
+ if (
59
+ _TRIAL_PROJECT.fullmatch(project)
60
+ and Path(working_dir).is_absolute()
61
+ and Path(working_dir).resolve().is_relative_to(suite)
62
+ ):
63
+ found.append({"id": container, "name": name, "project": project})
64
+ return found
65
+
66
+
67
+ def stop_left_running(
68
+ evidence: Path,
69
+ *,
70
+ expected_runtime: dict[str, object],
71
+ progress: Callable[[str], None] | None,
72
+ ) -> None:
73
+ """Stop every running container of this run's trials. Only the run.lock holder calls this.
74
+
75
+ The holder is the only process working on this run, so whatever is still running was left
76
+ by an attempt that a kill interrupted.
77
+ """
78
+ found = running(evidence, expected_runtime=expected_runtime)
79
+ for project in dict.fromkeys(container["project"] for container in found):
80
+ ids = [container["id"] for container in found if container["project"] == project]
81
+ try:
82
+ harbor_backend._docker("stop", "--time", "10", *ids, expected_runtime=expected_runtime)
83
+ except ValueError:
84
+ # A surviving Harbor worker may remove its own containers in the meantime; only a
85
+ # container that is still running is a failure.
86
+ still = running(evidence, expected_runtime=expected_runtime)
87
+ if any(container["id"] in ids for container in still):
88
+ raise
89
+ continue
90
+ if progress:
91
+ progress(
92
+ f"Stopped {project} ({len(ids)} container{'s' * (len(ids) != 1)}): left running "
93
+ "by an attempt that a kill interrupted"
94
+ )