agent-hitch 0.2.7 → 0.2.8

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 (287) hide show
  1. package/README.md +16 -2
  2. package/README.zh-CN.md +14 -2
  3. package/dist/bin/hitch.js +25 -3
  4. package/dist/bin/hitch.js.map +1 -1
  5. package/dist/scripts/canary-benchmark.js +54 -0
  6. package/dist/scripts/canary-benchmark.js.map +1 -0
  7. package/dist/scripts/canary-eval-scheduler-throughput.js +47 -0
  8. package/dist/scripts/canary-eval-scheduler-throughput.js.map +1 -0
  9. package/dist/scripts/check-architecture.js +3 -2
  10. package/dist/scripts/check-architecture.js.map +1 -1
  11. package/dist/src/adapters/catalog.js +2 -1
  12. package/dist/src/adapters/catalog.js.map +1 -1
  13. package/dist/src/adapters/providers/codex-auth.js +29 -0
  14. package/dist/src/adapters/providers/codex-auth.js.map +1 -0
  15. package/dist/src/adapters/providers/codex.js +23 -6
  16. package/dist/src/adapters/providers/codex.js.map +1 -1
  17. package/dist/src/adapters/providers/model-call.js +26 -0
  18. package/dist/src/adapters/providers/model-call.js.map +1 -0
  19. package/dist/src/artifacts/index.js +1 -1
  20. package/dist/src/artifacts/index.js.map +1 -1
  21. package/dist/src/artifacts/preparer.js +7 -2
  22. package/dist/src/artifacts/preparer.js.map +1 -1
  23. package/dist/src/backends/harbor/agent-budget.js +31 -0
  24. package/dist/src/backends/harbor/agent-budget.js.map +1 -0
  25. package/dist/src/backends/harbor/backend.js +6 -4
  26. package/dist/src/backends/harbor/backend.js.map +1 -1
  27. package/dist/src/backends/harbor/index.js +1 -0
  28. package/dist/src/backends/harbor/index.js.map +1 -1
  29. package/dist/src/backends/harbor/regrade.js +77 -0
  30. package/dist/src/backends/harbor/regrade.js.map +1 -0
  31. package/dist/src/backends/index.js +1 -0
  32. package/dist/src/backends/index.js.map +1 -1
  33. package/dist/src/benchmarks/index.js +3 -0
  34. package/dist/src/benchmarks/index.js.map +1 -0
  35. package/dist/src/benchmarks/loader.js +271 -0
  36. package/dist/src/benchmarks/loader.js.map +1 -0
  37. package/dist/src/benchmarks/metrics.js +16 -0
  38. package/dist/src/benchmarks/metrics.js.map +1 -0
  39. package/dist/src/benchmarks/toml.js +13 -0
  40. package/dist/src/benchmarks/toml.js.map +1 -0
  41. package/dist/src/benchmarks/validation.js +242 -0
  42. package/dist/src/benchmarks/validation.js.map +1 -0
  43. package/dist/src/cli/arguments.js +6 -4
  44. package/dist/src/cli/arguments.js.map +1 -1
  45. package/dist/src/cli/commands/benchmark.js +22 -0
  46. package/dist/src/cli/commands/benchmark.js.map +1 -0
  47. package/dist/src/cli/commands/capabilities.js +18 -0
  48. package/dist/src/cli/commands/capabilities.js.map +1 -0
  49. package/dist/src/cli/commands/eval.js +15 -3
  50. package/dist/src/cli/commands/eval.js.map +1 -1
  51. package/dist/src/cli/commands/run.js +37 -15
  52. package/dist/src/cli/commands/run.js.map +1 -1
  53. package/dist/src/cli/commands/trajectory.js +96 -5
  54. package/dist/src/cli/commands/trajectory.js.map +1 -1
  55. package/dist/src/cli/commands/verifier.js +36 -0
  56. package/dist/src/cli/commands/verifier.js.map +1 -0
  57. package/dist/src/cli/main.js +6 -0
  58. package/dist/src/cli/main.js.map +1 -1
  59. package/dist/src/cli/output.js +14 -2
  60. package/dist/src/cli/output.js.map +1 -1
  61. package/dist/src/control-plane/eval-control-work.js +5 -0
  62. package/dist/src/control-plane/eval-control-work.js.map +1 -1
  63. package/dist/src/control-plane/eval-scheduler.js +4 -1
  64. package/dist/src/control-plane/eval-scheduler.js.map +1 -1
  65. package/dist/src/control-plane/index.js +1 -1
  66. package/dist/src/control-plane/index.js.map +1 -1
  67. package/dist/src/control-plane/remote-result-transport.js +3 -0
  68. package/dist/src/control-plane/remote-result-transport.js.map +1 -1
  69. package/dist/src/control-plane/remote-work-coordinator.js +1 -0
  70. package/dist/src/control-plane/remote-work-coordinator.js.map +1 -1
  71. package/dist/src/control-plane/remote-work-item.js +62 -0
  72. package/dist/src/control-plane/remote-work-item.js.map +1 -0
  73. package/dist/src/control-plane/remote-work-recovery.js +44 -5
  74. package/dist/src/control-plane/remote-work-recovery.js.map +1 -1
  75. package/dist/src/control-plane/remote-worker-protocol.js +2 -23
  76. package/dist/src/control-plane/remote-worker-protocol.js.map +1 -1
  77. package/dist/src/control-plane/rerun-scheduler.js +51 -14
  78. package/dist/src/control-plane/rerun-scheduler.js.map +1 -1
  79. package/dist/src/control-plane/rerun-submission.js +10 -4
  80. package/dist/src/control-plane/rerun-submission.js.map +1 -1
  81. package/dist/src/control-plane/work-admission.js +2 -0
  82. package/dist/src/control-plane/work-admission.js.map +1 -1
  83. package/dist/src/control-plane/work-dispatcher.js +9 -1
  84. package/dist/src/control-plane/work-dispatcher.js.map +1 -1
  85. package/dist/src/controller-runtime/hash.js +7 -2
  86. package/dist/src/controller-runtime/hash.js.map +1 -1
  87. package/dist/src/daemon/auth.js +5 -0
  88. package/dist/src/daemon/auth.js.map +1 -1
  89. package/dist/src/domain/benchmarks.js +2 -0
  90. package/dist/src/domain/benchmarks.js.map +1 -0
  91. package/dist/src/domain/index.js +4 -0
  92. package/dist/src/domain/index.js.map +1 -1
  93. package/dist/src/domain/runs.js.map +1 -1
  94. package/dist/src/domain/validation.js +11 -4
  95. package/dist/src/domain/validation.js.map +1 -1
  96. package/dist/src/domain/verifier-evidence-validation.js +214 -0
  97. package/dist/src/domain/verifier-evidence-validation.js.map +1 -0
  98. package/dist/src/domain/verifier-evidence.js +3 -0
  99. package/dist/src/domain/verifier-evidence.js.map +1 -0
  100. package/dist/src/domain/verifier-score-contract.js +232 -0
  101. package/dist/src/domain/verifier-score-contract.js.map +1 -0
  102. package/dist/src/evals/benchmark-adapter-manifest.js +219 -0
  103. package/dist/src/evals/benchmark-adapter-manifest.js.map +1 -0
  104. package/dist/src/evals/benchmark-candidate.js +36 -0
  105. package/dist/src/evals/benchmark-candidate.js.map +1 -0
  106. package/dist/src/evals/benchmark-run.js +166 -0
  107. package/dist/src/evals/benchmark-run.js.map +1 -0
  108. package/dist/src/evals/collect-only-rerun.js +2 -1
  109. package/dist/src/evals/collect-only-rerun.js.map +1 -1
  110. package/dist/src/evals/directory.js +1 -1
  111. package/dist/src/evals/directory.js.map +1 -1
  112. package/dist/src/evals/duration-estimator.js +126 -0
  113. package/dist/src/evals/duration-estimator.js.map +1 -0
  114. package/dist/src/evals/eval-lifecycle-events.js +13 -0
  115. package/dist/src/evals/eval-lifecycle-events.js.map +1 -1
  116. package/dist/src/evals/eval-logical-plan.js +51 -0
  117. package/dist/src/evals/eval-logical-plan.js.map +1 -0
  118. package/dist/src/evals/eval-result-builder.js +81 -0
  119. package/dist/src/evals/eval-result-builder.js.map +1 -0
  120. package/dist/src/evals/evolution-baseline.js +191 -0
  121. package/dist/src/evals/evolution-baseline.js.map +1 -0
  122. package/dist/src/evals/execution-plan.js +39 -3
  123. package/dist/src/evals/execution-plan.js.map +1 -1
  124. package/dist/src/evals/failure-classifier.js +61 -0
  125. package/dist/src/evals/failure-classifier.js.map +1 -0
  126. package/dist/src/evals/harbor-artifact-builder.js +6 -2
  127. package/dist/src/evals/harbor-artifact-builder.js.map +1 -1
  128. package/dist/src/evals/harbor-bridge-error.js +41 -1
  129. package/dist/src/evals/harbor-bridge-error.js.map +1 -1
  130. package/dist/src/evals/harbor-node-runtime.js +146 -0
  131. package/dist/src/evals/harbor-node-runtime.js.map +1 -0
  132. package/dist/src/evals/index.js +15 -0
  133. package/dist/src/evals/index.js.map +1 -1
  134. package/dist/src/evals/infrastructure-retry.js +109 -22
  135. package/dist/src/evals/infrastructure-retry.js.map +1 -1
  136. package/dist/src/evals/native-phase-evidence.js +440 -0
  137. package/dist/src/evals/native-phase-evidence.js.map +1 -0
  138. package/dist/src/evals/physical-retry-work.js +31 -0
  139. package/dist/src/evals/physical-retry-work.js.map +1 -0
  140. package/dist/src/evals/planned-execution-support.js +22 -10
  141. package/dist/src/evals/planned-execution-support.js.map +1 -1
  142. package/dist/src/evals/planned-execution.js +120 -112
  143. package/dist/src/evals/planned-execution.js.map +1 -1
  144. package/dist/src/evals/planned-progress-publisher.js +62 -0
  145. package/dist/src/evals/planned-progress-publisher.js.map +1 -0
  146. package/dist/src/evals/planned-retry-execution.js +174 -0
  147. package/dist/src/evals/planned-retry-execution.js.map +1 -0
  148. package/dist/src/evals/planned-retry-lifecycle.js +4 -2
  149. package/dist/src/evals/planned-retry-lifecycle.js.map +1 -1
  150. package/dist/src/evals/preparation-rerun.js +225 -0
  151. package/dist/src/evals/preparation-rerun.js.map +1 -0
  152. package/dist/src/evals/progress.js +48 -8
  153. package/dist/src/evals/progress.js.map +1 -1
  154. package/dist/src/evals/recovery.js +54 -4
  155. package/dist/src/evals/recovery.js.map +1 -1
  156. package/dist/src/evals/regrade-evidence.js +77 -0
  157. package/dist/src/evals/regrade-evidence.js.map +1 -0
  158. package/dist/src/evals/remote-infrastructure-retry.js +84 -18
  159. package/dist/src/evals/remote-infrastructure-retry.js.map +1 -1
  160. package/dist/src/evals/request.js +21 -3
  161. package/dist/src/evals/request.js.map +1 -1
  162. package/dist/src/evals/rerun-inputs.js +19 -0
  163. package/dist/src/evals/rerun-inputs.js.map +1 -1
  164. package/dist/src/evals/rerun-types.js +2 -6
  165. package/dist/src/evals/rerun-types.js.map +1 -1
  166. package/dist/src/evals/rerun.js +27 -20
  167. package/dist/src/evals/rerun.js.map +1 -1
  168. package/dist/src/evals/result-helpers.js +13 -0
  169. package/dist/src/evals/result-helpers.js.map +1 -1
  170. package/dist/src/evals/retry-backoff.js +20 -0
  171. package/dist/src/evals/retry-backoff.js.map +1 -0
  172. package/dist/src/evals/retry-state.js +195 -0
  173. package/dist/src/evals/retry-state.js.map +1 -0
  174. package/dist/src/evals/scheduler-metrics.js +95 -0
  175. package/dist/src/evals/scheduler-metrics.js.map +1 -0
  176. package/dist/src/evals/scheduler-trace-replay.js +107 -0
  177. package/dist/src/evals/scheduler-trace-replay.js.map +1 -0
  178. package/dist/src/evals/service.js +87 -138
  179. package/dist/src/evals/service.js.map +1 -1
  180. package/dist/src/evals/trial-import.js +47 -50
  181. package/dist/src/evals/trial-import.js.map +1 -1
  182. package/dist/src/evals/trial-publication-recovery.js +45 -5
  183. package/dist/src/evals/trial-publication-recovery.js.map +1 -1
  184. package/dist/src/evals/trial-reference-validation.js +49 -0
  185. package/dist/src/evals/trial-reference-validation.js.map +1 -0
  186. package/dist/src/evals/verifier-artifacts.js +317 -0
  187. package/dist/src/evals/verifier-artifacts.js.map +1 -0
  188. package/dist/src/evals/verifier-eligibility.js +25 -0
  189. package/dist/src/evals/verifier-eligibility.js.map +1 -0
  190. package/dist/src/evals/verifier-only-rerun.js +210 -0
  191. package/dist/src/evals/verifier-only-rerun.js.map +1 -0
  192. package/dist/src/evals/verifier-runtime.js +61 -0
  193. package/dist/src/evals/verifier-runtime.js.map +1 -0
  194. package/dist/src/evals/verifier-score-artifacts.js +119 -0
  195. package/dist/src/evals/verifier-score-artifacts.js.map +1 -0
  196. package/dist/src/foundation/contained-file.js +85 -0
  197. package/dist/src/foundation/contained-file.js.map +1 -0
  198. package/dist/src/foundation/credential-redaction.js +26 -1
  199. package/dist/src/foundation/credential-redaction.js.map +1 -1
  200. package/dist/src/foundation/fs.js +2 -1
  201. package/dist/src/foundation/fs.js.map +1 -1
  202. package/dist/src/foundation/index.js +2 -1
  203. package/dist/src/foundation/index.js.map +1 -1
  204. package/dist/src/runs/adapter-process.js +12 -0
  205. package/dist/src/runs/adapter-process.js.map +1 -0
  206. package/dist/src/runs/executor.js +24 -30
  207. package/dist/src/runs/executor.js.map +1 -1
  208. package/dist/src/runs/finalizer.js +17 -0
  209. package/dist/src/runs/finalizer.js.map +1 -1
  210. package/dist/src/runs/index.js +5 -0
  211. package/dist/src/runs/index.js.map +1 -1
  212. package/dist/src/runs/phase-bundle.js +86 -0
  213. package/dist/src/runs/phase-bundle.js.map +1 -0
  214. package/dist/src/runs/phase-cancellation.js +107 -0
  215. package/dist/src/runs/phase-cancellation.js.map +1 -0
  216. package/dist/src/runs/phase-group.js +113 -0
  217. package/dist/src/runs/phase-group.js.map +1 -0
  218. package/dist/src/runs/query.js +1 -1
  219. package/dist/src/runs/query.js.map +1 -1
  220. package/dist/src/runs/records.js +3 -0
  221. package/dist/src/runs/records.js.map +1 -1
  222. package/dist/src/runs/request.js +4 -2
  223. package/dist/src/runs/request.js.map +1 -1
  224. package/dist/src/runs/verifier-evidence-redaction.js +61 -0
  225. package/dist/src/runs/verifier-evidence-redaction.js.map +1 -0
  226. package/dist/src/runs/verifier-evidence.js +459 -0
  227. package/dist/src/runs/verifier-evidence.js.map +1 -0
  228. package/dist/src/runs/verifier-structured-evidence.js +83 -0
  229. package/dist/src/runs/verifier-structured-evidence.js.map +1 -0
  230. package/dist/src/trajectories/analysis.js +288 -0
  231. package/dist/src/trajectories/analysis.js.map +1 -0
  232. package/dist/src/trajectories/chunk-projection.js +148 -0
  233. package/dist/src/trajectories/chunk-projection.js.map +1 -0
  234. package/dist/src/trajectories/content-projection.js +389 -0
  235. package/dist/src/trajectories/content-projection.js.map +1 -0
  236. package/dist/src/trajectories/dsh-chunk-contract.js +176 -0
  237. package/dist/src/trajectories/dsh-chunk-contract.js.map +1 -0
  238. package/dist/src/trajectories/dsh-contract.js +284 -0
  239. package/dist/src/trajectories/dsh-contract.js.map +1 -0
  240. package/dist/src/trajectories/events-chunk-drill.js +95 -0
  241. package/dist/src/trajectories/events-chunk-drill.js.map +1 -0
  242. package/dist/src/trajectories/events-page.js +367 -0
  243. package/dist/src/trajectories/events-page.js.map +1 -0
  244. package/dist/src/trajectories/index.js +3 -0
  245. package/dist/src/trajectories/index.js.map +1 -1
  246. package/dist/src/trajectories/request-attempt.js +47 -0
  247. package/dist/src/trajectories/request-attempt.js.map +1 -0
  248. package/dist/src/trajectories/stream-reader.js +311 -0
  249. package/dist/src/trajectories/stream-reader.js.map +1 -0
  250. package/dist/src/trajectories/surface-fold.js +126 -0
  251. package/dist/src/trajectories/surface-fold.js.map +1 -0
  252. package/dist/src/workers/remote-harbor-worker.js +3 -0
  253. package/dist/src/workers/remote-harbor-worker.js.map +1 -1
  254. package/docs/schemas/benchmark-hook-request-v1.schema.json +65 -0
  255. package/docs/schemas/benchmark-hook-response-v1.schema.json +75 -0
  256. package/docs/schemas/benchmark-hook-v1.schema.json +33 -0
  257. package/docs/schemas/benchmark-lock-v1.schema.json +351 -0
  258. package/docs/schemas/benchmark-metric-v1.schema.json +37 -0
  259. package/docs/schemas/benchmark-package-v1.schema.json +167 -0
  260. package/docs/schemas/benchmark-phase-group.schema.json +41 -0
  261. package/docs/schemas/benchmark-profile-v1.schema.json +141 -0
  262. package/docs/schemas/benchmark-task-v1.schema.json +384 -0
  263. package/docs/schemas/benchmark-tool-result-v1.schema.json +30 -0
  264. package/docs/schemas/error.schema.json +22 -0
  265. package/docs/schemas/eval-progress.schema.json +1 -15
  266. package/docs/schemas/eval-rerun-result.schema.json +6 -1
  267. package/docs/schemas/eval-rerun-submission.schema.json +3 -1
  268. package/docs/schemas/eval-result.schema.json +1 -15
  269. package/docs/schemas/eval-trial-publication.schema.json +1 -25
  270. package/docs/schemas/eval-trial-reference.schema.json +129 -0
  271. package/docs/schemas/regrade-assessment-reference.schema.json +11 -0
  272. package/docs/schemas/run-context.schema.json +15 -0
  273. package/docs/schemas/run-manifest.schema.json +4 -0
  274. package/docs/schemas/run-request.schema.json +4 -0
  275. package/docs/schemas/trajectory-analysis.schema.json +258 -0
  276. package/docs/schemas/trajectory-events-page.schema.json +40 -0
  277. package/docs/schemas/verifier-assessment.schema.json +77 -0
  278. package/docs/schemas/verifier-evidence.schema.json +276 -0
  279. package/integrations/harbor/hitch_benchmark.py +252 -0
  280. package/integrations/harbor/hitch_candidate_recycle.py +262 -0
  281. package/integrations/harbor/hitch_harbor_agent.py +541 -43
  282. package/integrations/harbor/hitch_harbor_environment.py +138 -0
  283. package/integrations/harbor/hitch_harbor_verifier.py +60 -2
  284. package/integrations/harbor/hitch_phase_supervisor.py +452 -0
  285. package/integrations/harbor/hitch_tool_client.mjs +91 -0
  286. package/integrations/model-call/cli.js +57 -0
  287. package/package.json +17 -6
@@ -0,0 +1,262 @@
1
+ """Retire a candidate container without resetting the benchmark's sidecars.
2
+
3
+ Trusted host API only. The caller must revoke the old tool binding and finish
4
+ exporting the old run before calling this API, then reinstall the harness and
5
+ bind the next run before starting a model. Receipts prove environment changes,
6
+ not model conversation freshness, phase completion, or benchmark correctness.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import re
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+
20
+ class CandidateRecycleError(RuntimeError):
21
+ pass
22
+
23
+
24
+ def _overlaps(left: Path, right: Path) -> bool:
25
+ return left == right or left in right.parents or right in left.parents
26
+
27
+
28
+ def _directory(path: Path) -> Path:
29
+ absolute = path.absolute()
30
+ if absolute.resolve(strict=True) != absolute or not absolute.is_dir():
31
+ raise CandidateRecycleError("Candidate phase paths must be real directories without symlinks")
32
+ return absolute
33
+
34
+
35
+ def _write_json(path: Path, value: Any) -> None:
36
+ temporary = path.with_suffix(".pending")
37
+ with temporary.open("x", encoding="utf-8") as stream:
38
+ os.chmod(temporary, 0o600)
39
+ json.dump(value, stream, sort_keys=True, indent=2, allow_nan=False)
40
+ stream.write("\n")
41
+ stream.flush()
42
+ os.fsync(stream.fileno())
43
+ temporary.replace(path)
44
+
45
+
46
+ async def _docker(arguments: list[str]) -> str:
47
+ process = await asyncio.create_subprocess_exec(
48
+ "docker", *arguments, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
49
+ )
50
+ try:
51
+ stdout, _ = await asyncio.wait_for(process.communicate(), timeout=60)
52
+ except BaseException:
53
+ if process.returncode is None:
54
+ process.kill()
55
+ await process.wait()
56
+ raise
57
+ if process.returncode:
58
+ # Docker errors can contain environment values. Never journal them.
59
+ raise CandidateRecycleError("Candidate Docker inspection failed")
60
+ return stdout.decode("utf-8")
61
+
62
+
63
+ _INSPECT = ('{"id":{{json .Id}},"image":{{json .Image}},'
64
+ '"state":{{json .State.Status}},"started_at":{{json .State.StartedAt}},'
65
+ '"labels":{{json .Config.Labels}},"mounts":{{json .Mounts}},'
66
+ '"host":{{json .HostConfig}},"config":{{json .Config}}}')
67
+
68
+
69
+ def _configuration_digest(container: dict[str, Any]) -> str:
70
+ # Compare configuration without persisting potentially secret environment
71
+ # values. Compose regenerates its own config-hash on the image-pin overlay.
72
+ config = dict(container["config"])
73
+ config.pop("Image", None)
74
+ config.pop("Hostname", None)
75
+ environment = config.get("Env") or []
76
+ # Docker/Compose may reorder map-derived env and bind lists. Unique env
77
+ # keys have no ordering semantics; duplicate keys must retain their order.
78
+ if len({entry.split("=", 1)[0] for entry in environment}) == len(environment):
79
+ config["Env"] = sorted(environment)
80
+ config["Labels"] = {key: value for key, value in (config.get("Labels") or {}).items()
81
+ if not key.startswith("com.docker.compose.") and key != "io.hitch.candidate-phase"}
82
+ host = dict(container["host"])
83
+ # These two are generated per container by Docker, not requested resources.
84
+ host.pop("ContainerIDFile", None)
85
+ if host.get("Binds"):
86
+ host["Binds"] = sorted(host["Binds"])
87
+ digest = hashlib.sha256(json.dumps([config, host], sort_keys=True).encode()).hexdigest()
88
+ return f"sha256:{digest}"
89
+
90
+
91
+ class CandidateRecycler:
92
+ def __init__(self, environment: Any):
93
+ self.environment = environment
94
+ self.lock = asyncio.Lock()
95
+
96
+ async def _compose(self, arguments: list[str]) -> str:
97
+ result = await self.environment._run_docker_compose_command(
98
+ arguments, check=False, timeout_sec=60
99
+ )
100
+ if result.return_code:
101
+ raise CandidateRecycleError("Candidate Compose operation failed; trial cleanup required")
102
+ return result.stdout or ""
103
+
104
+ async def _containers(self) -> dict[str, dict[str, Any]]:
105
+ ids = (await self._compose(["ps", "--all", "--quiet"])).split()
106
+ if not ids or len(ids) > 64 or any(not re.fullmatch(r"[0-9a-f]{64}", item) for item in ids):
107
+ raise CandidateRecycleError("Candidate project container inventory is invalid")
108
+ documents = await _docker(["inspect", "--format", _INSPECT, *ids])
109
+ containers: dict[str, dict[str, Any]] = {}
110
+ ownership = self.environment._hitch_ownership_labels
111
+ if not ownership:
112
+ raise CandidateRecycleError("Candidate recycling requires Hitch lease ownership")
113
+ projects = set()
114
+ for line in documents.splitlines():
115
+ item = json.loads(line)
116
+ labels = item["labels"] or {}
117
+ if any(labels.get(key) != value for key, value in ownership.items()):
118
+ raise CandidateRecycleError("Candidate project lease ownership changed")
119
+ service = labels.get("com.docker.compose.service")
120
+ projects.add(labels.get("com.docker.compose.project"))
121
+ if not service or service in containers or labels.get("com.docker.compose.oneoff", "False").lower() != "false":
122
+ raise CandidateRecycleError("Candidate recycling requires one container per service")
123
+ containers[service] = item
124
+ if len(containers) != len(ids) or "main" not in containers or len(projects) != 1 or None in projects:
125
+ raise CandidateRecycleError("Candidate project inventory is incomplete")
126
+ return containers
127
+
128
+ @staticmethod
129
+ def _sidecars(containers: dict[str, dict[str, Any]]) -> dict[str, Any]:
130
+ result = {}
131
+ for service, item in containers.items():
132
+ if service != "main":
133
+ if item["state"] != "running":
134
+ raise CandidateRecycleError("A benchmark sidecar is not running")
135
+ result[service] = {"id": item["id"], "image": item["image"], "started_at": item["started_at"]}
136
+ return result
137
+
138
+ def _mounts(self, containers: dict[str, dict[str, Any]], archive: Path) -> dict[str, Path]:
139
+ paths = self.environment.trial_paths
140
+ allowed = {"/logs/agent": paths.agent_dir, "/logs/verifier": paths.verifier_dir,
141
+ "/logs/artifacts": paths.artifacts_dir}
142
+ main = containers["main"]
143
+ host = main["host"]
144
+ if (host.get("Privileged") or host.get("VolumesFrom") or host.get("Devices") or host.get("CapAdd")
145
+ or host.get("PidMode") or host.get("IpcMode") in ("host",) or str(host.get("IpcMode", "")).startswith("container:")
146
+ or host.get("NetworkMode") == "host"):
147
+ raise CandidateRecycleError("Candidate container isolation configuration is unsupported")
148
+ writable = {}
149
+ all_sources = []
150
+ for mount in main["mounts"]:
151
+ if mount["Type"] == "tmpfs":
152
+ continue # Recreated with the container.
153
+ if mount["Type"] != "bind":
154
+ raise CandidateRecycleError("Persistent candidate volumes are unsupported for phase recycling")
155
+ source = Path(mount["Source"]).absolute()
156
+ if source.resolve(strict=True) != source:
157
+ raise CandidateRecycleError("Candidate mount source contains a symlink")
158
+ if not source.is_file() and not source.is_dir():
159
+ raise CandidateRecycleError("Special-file candidate mounts are unsupported")
160
+ if _overlaps(source, archive):
161
+ raise CandidateRecycleError("Candidate can access the phase archive")
162
+ all_sources.append(source)
163
+ if mount["RW"]:
164
+ target = mount["Destination"]
165
+ if target not in allowed or source != Path(allowed[target]).absolute():
166
+ raise CandidateRecycleError("Only Harbor trial log directories may be writable candidate mounts")
167
+ writable[target] = _directory(source)
168
+ if ("/logs/agent" not in writable or len(set(writable.values())) != len(writable)
169
+ or len({source.name for source in writable.values()}) != len(writable)):
170
+ raise CandidateRecycleError("Candidate phase log mounts are missing or aliased")
171
+ for source in writable.values():
172
+ if sum(_overlaps(source, other) for other in all_sources) != 1:
173
+ raise CandidateRecycleError("Candidate mount paths overlap")
174
+ for service, item in containers.items():
175
+ if service != "main" and any(mount["Type"] == "bind" and _overlaps(source, Path(mount["Source"])) for mount in item["mounts"]):
176
+ raise CandidateRecycleError("Candidate logs are shared with a persistent service")
177
+ return writable
178
+
179
+ async def recycle(self, phase_index: int) -> dict[str, Any]:
180
+ if type(phase_index) is not int or not 1 <= phase_index <= 9999:
181
+ raise CandidateRecycleError("Candidate phase index must be an integer from 1 to 9999")
182
+ async with self.lock:
183
+ if getattr(self.environment, "_is_windows_container", False):
184
+ raise CandidateRecycleError("Candidate recycling supports Linux containers only")
185
+ trial = _directory(self.environment.trial_paths.trial_dir)
186
+ root = trial / "hitch-candidate-phases"
187
+ before = await self._containers()
188
+ mounts = self._mounts(before, root)
189
+ sidecars = self._sidecars(before)
190
+ phase = root / f"phase-{phase_index:04d}"
191
+ receipt_path = phase / "receipt.json"
192
+ root.mkdir(mode=0o700, exist_ok=True)
193
+ _directory(root)
194
+ if root.stat().st_mode & 0o077:
195
+ raise CandidateRecycleError("Candidate phase archive must be private to the host controller")
196
+ if receipt_path.exists():
197
+ _directory(phase)
198
+ if receipt_path.is_symlink():
199
+ raise CandidateRecycleError("Candidate phase receipt is a symlink")
200
+ receipt = json.loads(receipt_path.read_text())
201
+ if (receipt.get("status") != "completed" or receipt.get("new_container_id") != before["main"]["id"]
202
+ or receipt.get("sidecars") != sidecars or receipt.get("ownership") != self.environment._hitch_ownership_labels):
203
+ raise CandidateRecycleError("Candidate recycle receipt is stale or incomplete; cleanup required")
204
+ return receipt
205
+ if phase.exists():
206
+ raise CandidateRecycleError("Candidate phase archive already exists; cleanup required")
207
+ existing = sorted(item.name for item in root.iterdir())
208
+ if existing != [f"phase-{index:04d}" for index in range(1, phase_index)]:
209
+ raise CandidateRecycleError("Candidate phases must be recycled consecutively")
210
+ if phase_index > 1:
211
+ previous = json.loads((root / f"phase-{phase_index - 1:04d}" / "receipt.json").read_text())
212
+ if (previous.get("status") != "completed" or previous.get("new_container_id") != before["main"]["id"]
213
+ or previous.get("sidecars") != sidecars or previous.get("image") != before["main"]["image"]):
214
+ raise CandidateRecycleError("Previous candidate replacement is not complete")
215
+ main = before["main"]
216
+ if not re.fullmatch(r"sha256:[0-9a-f]{64}", main["image"]):
217
+ raise CandidateRecycleError("Candidate image identity is invalid")
218
+ phase.mkdir(mode=0o700)
219
+ receipt = {"schema_version": "hitch-candidate-recycle@1", "scope": "environment-only",
220
+ "phase_index": phase_index, "status": "prepared", "old_container_id": main["id"],
221
+ "image": main["image"], "configuration_digest": _configuration_digest(main),
222
+ "ownership": dict(self.environment._hitch_ownership_labels), "sidecars": sidecars,
223
+ "archives": {target: f"phase-{phase_index:04d}/{source.name}" for target, source in mounts.items()}}
224
+ _write_json(receipt_path, receipt)
225
+ try:
226
+ # Deliberately bypass stop_service(): that hook snapshots the
227
+ # entire benchmark and would terminate the native phase loop.
228
+ await self._compose(["rm", "--stop", "--force", "--volumes", "main"])
229
+ remaining = set((await _docker(["ps", "--all", "--no-trunc", "--quiet"])).split())
230
+ if main["id"] in remaining:
231
+ raise CandidateRecycleError("Previous candidate container still exists")
232
+ receipt["status"] = "retired"
233
+ _write_json(receipt_path, receipt)
234
+ for source in mounts.values():
235
+ _directory(source)
236
+ mode = source.stat().st_mode & 0o777
237
+ source.rename(phase / source.name)
238
+ source.mkdir(mode=mode)
239
+ source.chmod(mode)
240
+ receipt["status"] = "archived"
241
+ _write_json(receipt_path, receipt)
242
+ overlay = phase / "replacement.json"
243
+ _write_json(overlay, {"services": {"main": {"image": main["image"], "labels": {"io.hitch.candidate-phase": str(phase_index + 1)}}}})
244
+ self.environment._hitch_phase_compose_path = overlay
245
+ await self._compose(["up", "--detach", "--wait", "--wait-timeout", "45", "--no-deps", "--no-build", "--pull", "never", "main"])
246
+ after = await self._containers()
247
+ next_main = after["main"]
248
+ checks = {"new-container": next_main["id"] != main["id"], "image": next_main["image"] == main["image"],
249
+ "running": next_main["state"] == "running", "configuration": _configuration_digest(next_main) == receipt["configuration_digest"],
250
+ "sidecars": self._sidecars(after) == sidecars, "mounts": self._mounts(after, root) == mounts}
251
+ failed_checks = [name for name, passed in checks.items() if not passed]
252
+ if failed_checks:
253
+ raise CandidateRecycleError("Replacement changed the benchmark environment contract: " + ", ".join(failed_checks))
254
+ await self.environment.ensure_dirs(list(mounts))
255
+ await self.environment._upload_environment_dir_after_start()
256
+ receipt.update(status="completed", new_container_id=next_main["id"])
257
+ _write_json(receipt_path, receipt)
258
+ return receipt
259
+ except BaseException as error:
260
+ receipt.update(status="failed", failure_type=type(error).__name__)
261
+ _write_json(receipt_path, receipt)
262
+ raise