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
@@ -2,9 +2,12 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import hashlib
6
7
  import json
8
+ import os
7
9
  import re
10
+ import secrets
8
11
  import shlex
9
12
  import stat as stat_module
10
13
  import tempfile
@@ -13,7 +16,7 @@ import uuid
13
16
  from urllib.parse import urlparse
14
17
  from datetime import datetime, timezone
15
18
  from pathlib import Path, PurePosixPath
16
- from typing import Any
19
+ from typing import Any, NamedTuple
17
20
 
18
21
  from harbor.agents.base import BaseAgent
19
22
  from harbor.environments.base import BaseEnvironment, ExecResult
@@ -22,10 +25,69 @@ from harbor.models.agent.context import AgentContext
22
25
  CONTROLLER_RUNTIME_MANIFEST_VERSION = "2"
23
26
  HARNESS_ARTIFACT_REMOTE_ROOT = "/opt/hitch-harness-artifact"
24
27
  HITCH_BRIDGE_ERROR_LOG = "/logs/agent/hitch-bridge-error.json"
28
+ HITCH_AGENT_OUTCOME_NAME = "hitch-agent-outcome.json"
25
29
  HITCH_DIAGNOSTIC_MAX_BYTES = 8 * 1024
26
30
  HITCH_BRIDGE_ERROR_MAX_BYTES = 64 * 1024
27
31
  HITCH_RESULT_MISSING_EXIT = 44
28
32
  HITCH_RESULT_NOT_FILE_EXIT = 45
33
+ PHASE_EXPORT_MODULE = "dist/src/runs/phase-bundle.js"
34
+ PHASE_CONTROL_MODULE = "dist/src/runs/phase-cancellation.js"
35
+
36
+
37
+ def artifact_directory_integrity(directory: Path, captured_files: dict[str, bytes] | None = None) -> str:
38
+ """Match artifacts/integrity.ts, including modes and internal symlinks."""
39
+ root = Path(os.path.abspath(directory))
40
+ digest = hashlib.sha256()
41
+
42
+ def visit(parent: Path) -> None:
43
+ # JS Array.sort compares UTF-16 code units, not Unicode code points.
44
+ for entry in sorted(parent.iterdir(), key=lambda item: item.name.encode("utf-16-be", "surrogatepass")):
45
+ if parent == root and entry.name == "artifact.json":
46
+ continue
47
+ relative = entry.relative_to(root).as_posix()
48
+ info = entry.lstat()
49
+ mode = info.st_mode & 0o7777
50
+ if stat_module.S_ISDIR(info.st_mode):
51
+ digest.update(f"d\0{relative}\0{mode}\0".encode())
52
+ visit(entry)
53
+ elif stat_module.S_ISREG(info.st_mode):
54
+ digest.update(f"f\0{relative}\0{mode}\0{info.st_size}\0".encode())
55
+ capture = captured_files is not None and relative in captured_files
56
+ if capture and info.st_size > 16_384:
57
+ raise RuntimeError("captured artifact metadata exceeds its size limit")
58
+ content = bytearray()
59
+ with entry.open("rb") as handle:
60
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
61
+ digest.update(chunk)
62
+ if capture:
63
+ content.extend(chunk)
64
+ if len(content) > 16_384:
65
+ raise RuntimeError("captured artifact metadata exceeds its size limit")
66
+ if capture and captured_files is not None:
67
+ captured_files[relative] = bytes(content)
68
+ digest.update(b"\0")
69
+ elif stat_module.S_ISLNK(info.st_mode):
70
+ target = os.readlink(entry)
71
+ if not Path(os.path.abspath(entry.parent / target)).is_relative_to(root):
72
+ raise RuntimeError("artifact symlink escapes the artifact directory")
73
+ digest.update(f"l\0{relative}\0{target}\0".encode())
74
+ else:
75
+ raise RuntimeError("artifact contains a special file")
76
+
77
+ visit(root)
78
+ return "sha256:" + digest.hexdigest()
79
+
80
+
81
+ class PreparedPhase(NamedTuple):
82
+ run_id: str
83
+ instruction: str
84
+ context_json: str
85
+ parent_json: str
86
+ identity: str
87
+ deadline_ns: int
88
+
89
+ def __repr__(self) -> str:
90
+ return f"PreparedPhase(run_id={self.run_id!r})"
29
91
 
30
92
 
31
93
  class HitchBridgeError(RuntimeError):
@@ -78,6 +140,7 @@ class HitchHarborAgent(BaseAgent):
78
140
  if not isinstance(node_version, str) or re.fullmatch(r"v\d+\.\d+\.\d+", node_version) is None:
79
141
  raise ValueError("node_version must be an exact stable Node.js version")
80
142
  self.required_node_version = node_version
143
+ self._node_bin_directory: str | None = None
81
144
  if local_source_transport is not None:
82
145
  raise ValueError("trial-side local source transports are no longer supported")
83
146
  self.candidate_id = candidate_id
@@ -108,6 +171,17 @@ class HitchHarborAgent(BaseAgent):
108
171
  self._artifact_host_directory: Path | None = None
109
172
  self._artifact_uploaded = False
110
173
  self._artifact_transport_status: str | None = None
174
+ self._setup_complete = False
175
+ self._phase_export_available = False
176
+ self._phase_supervision_available = False
177
+ self._prepared_phase: PreparedPhase | None = None
178
+ self._prepared_phase_keys: set[tuple[str, int]] = set()
179
+ self._phase_group_contracts: dict[str, tuple[str, str, int]] = {}
180
+ self._phase_inflight = False
181
+ self._active_phase: PreparedPhase | None = None
182
+ self._phase_cancel_lock: asyncio.Lock | None = None
183
+ self._phase_cancel_receipts: dict[str, dict[str, Any]] = {}
184
+ self._phase_control_tokens: dict[str, str] = {}
111
185
 
112
186
  @staticmethod
113
187
  def name() -> str:
@@ -118,8 +192,10 @@ class HitchHarborAgent(BaseAgent):
118
192
 
119
193
  async def setup(self, environment: BaseEnvironment) -> None:
120
194
  started_ns = time.monotonic_ns()
195
+ self._setup_complete = False
121
196
  try:
122
197
  await self._setup(environment)
198
+ self._setup_complete = True
123
199
  finally:
124
200
  self._write_phase_timing("setup", started_ns)
125
201
 
@@ -147,6 +223,8 @@ class HitchHarborAgent(BaseAgent):
147
223
  # and the actual container upload, spec §4.6).
148
224
  self._verify_manifest_identity(manifest)
149
225
  self._verify_payload(manifest)
226
+ self._phase_export_available = {PHASE_EXPORT_MODULE, PHASE_CONTROL_MODULE}.issubset({file.get("path") for file in manifest["files"]})
227
+ self._phase_supervision_available = self._phase_export_available and "integrations/harbor/hitch_phase_supervisor.py" in {file.get("path") for file in manifest["files"]}
150
228
  if self.harness_artifact is None:
151
229
  raise RuntimeError("hitch-artifact-materialize: Harbor requires a dedicated-builder artifact")
152
230
  try:
@@ -513,26 +591,209 @@ class HitchHarborAgent(BaseAgent):
513
591
  environment: BaseEnvironment,
514
592
  context: AgentContext,
515
593
  ) -> None:
594
+ session = getattr(environment, "_hitch_benchmark", None)
595
+ config = session.config if session else None
596
+ driver = config["task"]["driver"] if config else None
597
+ phases = driver["config"].get("native_phases") if driver and driver["kind"] == "tool-server" else None
598
+ if phases:
599
+ from hitch_phase_supervisor import NativePhaseSupervisor
600
+ task_timeout = int(config["agent_timeout_sec"] * 1000)
601
+ remaining = min(task_timeout, self.hitch_timeout_ms) if self.hitch_timeout_ms > 0 else task_timeout
602
+ result = await NativePhaseSupervisor(
603
+ self, environment, controller={"service": driver["config"]["service"], "argv": phases["argv"]},
604
+ binding={"endpoint": driver["config"]["endpoint"], "tools": config["tools"]},
605
+ task_digest=config["task_digest"], timeout_ms=remaining,
606
+ shutdown_timeout_ms=phases["shutdown_timeout_ms"],
607
+ finalization_timeout_ms=phases.get("finalization_timeout_ms"),
608
+ task_instruction=instruction,
609
+ ).run()
610
+ context.metadata = {"candidate_id": self.candidate_id, "harness_ref": self.harness_ref,
611
+ "revision_identity": self.revision_identity, "controller_runtime_id": self.controller_runtime_id,
612
+ "hitch_context_kind": "benchmark_phase_group", "hitch_run_group_id": result["run_group_id"],
613
+ "hitch_phase_count": len(result["phases"]), "hitch_status": "succeeded",
614
+ "hitch_phase_supervision": "hitch-native-phases/supervision.json"}
615
+ return
516
616
  started_ns = time.monotonic_ns()
517
617
  try:
518
618
  await self._run(instruction, environment, context)
519
619
  finally:
520
620
  self._write_phase_timing("agent", started_ns)
521
621
 
622
+ def _phase_identity(self) -> str:
623
+ identity = [self.candidate_id, self.harness_ref, self.revision_identity, self.controller_runtime_id,
624
+ self.model_name, self.agent_args, self.credential_names, self._require_workdir(),
625
+ self.eval_id, self.benchmark_id, self.benchmark_revision, self.verifier_identity, self._trial_identity()]
626
+ return hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest()
627
+
628
+ def prepare_phase(self, *, instruction: str, run_group_id: str, phase_index: int,
629
+ task_digest: str, remaining_timeout_ms: int) -> PreparedPhase:
630
+ """Reserve identity before private tool binding; does not start a model.
631
+
632
+ The supervisor supplies one frozen task digest across all phases and a
633
+ remaining whole-task budget. Time spent binding/uploading consumes that
634
+ budget too. The returned immutable handle is single-use on this agent.
635
+ """
636
+ if not self._setup_complete or not self._phase_export_available:
637
+ raise RuntimeError("phase preparation requires setup with a phase-export capable runtime")
638
+ if self._prepared_phase is not None or self._phase_inflight:
639
+ raise RuntimeError("another candidate phase is already prepared or running")
640
+ if (not isinstance(instruction, str) or not instruction.strip()
641
+ or not isinstance(run_group_id, str) or not re.fullmatch(r"run_group_[a-f0-9]{32}", run_group_id)
642
+ or type(phase_index) is not int or not 1 <= phase_index <= 10000
643
+ or not isinstance(task_digest, str) or not re.fullmatch(r"sha256:[a-f0-9]{64}", task_digest)
644
+ or type(remaining_timeout_ms) is not int or not 1 <= remaining_timeout_ms <= 9007199254740991):
645
+ raise ValueError("invalid candidate phase identity or remaining budget")
646
+ if not all((self.eval_id, self.benchmark_id, self.benchmark_revision, self.verifier_identity)):
647
+ raise ValueError("candidate phases require a complete eval and benchmark identity")
648
+ if (not re.fullmatch(r"eval_[a-f0-9]{32}", self.eval_id)
649
+ or any(not re.fullmatch(r"sha256:[a-f0-9]{64}", value) for value in (self.benchmark_revision, self.verifier_identity))):
650
+ raise ValueError("candidate phase eval and benchmark identities must be immutable")
651
+ key = (run_group_id, phase_index)
652
+ if key in self._prepared_phase_keys:
653
+ raise RuntimeError("candidate phase identity was already prepared; implicit retries are forbidden")
654
+ identity = self._phase_identity()
655
+ previous = self._phase_group_contracts.get(run_group_id)
656
+ if (previous is None and phase_index != 1
657
+ or previous is not None and previous != (task_digest, identity, phase_index - 1)):
658
+ raise RuntimeError("candidate phase group must retain its task/candidate identity and consecutive indices")
659
+ trial_id, task_id, attempt = self._trial_identity()
660
+ context = {"kind": "benchmark_phase", "benchmark_id": self.benchmark_id,
661
+ "benchmark_revision": self.benchmark_revision, "task_id": task_id,
662
+ "task_digest": task_digest, "verifier_identity": self.verifier_identity,
663
+ "run_group_id": run_group_id, "phase_index": phase_index}
664
+ parent = {"kind": "eval", "eval_id": self.eval_id, "trial_id": trial_id, "attempt": attempt}
665
+ prepared = PreparedPhase("run_" + uuid.uuid4().hex, instruction, json.dumps(context, sort_keys=True),
666
+ json.dumps(parent, sort_keys=True), identity,
667
+ time.monotonic_ns() + remaining_timeout_ms * 1_000_000)
668
+ self._phase_control_tokens[prepared.run_id] = secrets.token_hex(32)
669
+ self._prepared_phase_keys.add(key)
670
+ self._phase_group_contracts[run_group_id] = (task_digest, identity, phase_index)
671
+ self._prepared_phase = prepared
672
+ return prepared
673
+
674
+ async def run_phase(self, prepared: PreparedPhase, environment: BaseEnvironment, context: AgentContext) -> None:
675
+ if not isinstance(prepared, PreparedPhase) or self._prepared_phase is not prepared or self._phase_inflight:
676
+ raise RuntimeError("candidate phase handle is stale, foreign, or already consumed")
677
+ self._prepared_phase = None # Every outcome consumes the handle.
678
+ if prepared.identity != self._phase_identity():
679
+ self._phase_control_tokens.pop(prepared.run_id, None)
680
+ raise RuntimeError("candidate identity changed after phase preparation")
681
+ if prepared.deadline_ns <= time.monotonic_ns():
682
+ self._phase_control_tokens.pop(prepared.run_id, None)
683
+ raise RuntimeError("candidate whole-task budget expired before phase start")
684
+ self._phase_inflight = True
685
+ self._active_phase = prepared
686
+ started_ns = time.monotonic_ns()
687
+ try:
688
+ await self._run(prepared.instruction, environment, context, prepared_phase=prepared)
689
+ finally:
690
+ self._phase_inflight = False
691
+ self._active_phase = None
692
+ self._phase_control_tokens.pop(prepared.run_id, None)
693
+ self._write_phase_timing("agent", started_ns)
694
+
695
+ @staticmethod
696
+ def _phase_control_path(prepared: PreparedPhase) -> str:
697
+ return f"/tmp/hitch-phase-control-{prepared.run_id}.config.json"
698
+
699
+ @staticmethod
700
+ async def _upload_phase_json(environment: BaseEnvironment, target: str, value: dict[str, Any]) -> None:
701
+ # Keep control nonces out of both argv and the mounted agent log tree.
702
+ with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", prefix="hitch-phase-control-", delete=False) as handle:
703
+ json.dump(value, handle)
704
+ temporary = Path(handle.name)
705
+ try:
706
+ await environment.upload_file(temporary, target)
707
+ result = await environment.exec(f"chmod 600 {shlex.quote(target)}")
708
+ if result.return_code != 0:
709
+ raise RuntimeError("could not protect candidate phase control input")
710
+ finally:
711
+ temporary.unlink(missing_ok=True)
712
+
713
+ async def request_phase_cancellation(self, prepared: PreparedPhase, environment: BaseEnvironment, *, reason: str) -> dict[str, Any]:
714
+ """Request executor cancellation; await run_phase separately for sealed evidence.
715
+
716
+ This receipt proves only that the request was delivered, not that the
717
+ model stopped or that a native phase completed. Await this operation
718
+ before recycling the candidate. A caller must separately enforce a
719
+ bounded shutdown/collection allowance and whole-trial failure cleanup.
720
+ """
721
+ if reason not in {"native_phase_reset", "native_task_finished", "task_budget_expired", "cancelled"}:
722
+ raise ValueError("invalid phase cancellation reason")
723
+ if self._phase_cancel_lock is None:
724
+ self._phase_cancel_lock = asyncio.Lock()
725
+ async with self._phase_cancel_lock:
726
+ if not isinstance(prepared, PreparedPhase) or self._active_phase is not prepared:
727
+ raise RuntimeError("candidate phase is not active")
728
+ existing = self._phase_cancel_receipts.get(prepared.run_id)
729
+ if existing is not None:
730
+ if existing["reason"] != reason:
731
+ raise RuntimeError("candidate phase cancellation reason already fixed")
732
+ if existing["status"] != "delivered":
733
+ raise RuntimeError("candidate cancellation delivery is incomplete; implicit retries are forbidden")
734
+ return dict(existing)
735
+ phase = json.loads(prepared.context_json)
736
+ record_dir = self.logs_dir.parent / "hitch-phase-control"
737
+ record_dir.mkdir(mode=0o700, exist_ok=True)
738
+ if record_dir.is_symlink() or record_dir.stat().st_mode & 0o077:
739
+ raise RuntimeError("candidate cancellation records require a private host directory")
740
+ record_path = record_dir / f"{prepared.run_id}.request.json"
741
+ receipt = {"schema_version": "hitch-phase-cancel-request@1", "scope": "request-only",
742
+ "status": "prepared",
743
+ "request_id": "phase_cancel_" + uuid.uuid4().hex, "run_id": prepared.run_id,
744
+ "run_group_id": phase["run_group_id"], "phase_index": phase["phase_index"],
745
+ "reason": reason, "requested_at": datetime.now(timezone.utc).isoformat(),
746
+ "record_ref": f"hitch-phase-control/{record_path.name}"}
747
+ with record_path.open("x", encoding="utf-8") as handle:
748
+ json.dump(receipt, handle, sort_keys=True)
749
+ handle.write("\n")
750
+ record_path.chmod(0o600)
751
+ self._phase_cancel_receipts[prepared.run_id] = receipt
752
+ try:
753
+ await self._upload_phase_json(environment, self._phase_control_path(prepared).replace(".config.json", ".request.json"), {
754
+ "schema_version": "hitch-phase-cancel@1", "run_id": prepared.run_id,
755
+ "token": self._phase_control_tokens[prepared.run_id], "reason": reason,
756
+ })
757
+ receipt["status"] = "delivered"
758
+ except BaseException as error:
759
+ receipt.update(status="delivery_failed", failure_type=type(error).__name__)
760
+ raise
761
+ finally:
762
+ update = record_path.with_suffix(".pending")
763
+ with update.open("x", encoding="utf-8") as handle:
764
+ json.dump(receipt, handle, sort_keys=True)
765
+ handle.write("\n")
766
+ update.chmod(0o600)
767
+ update.replace(record_path)
768
+ return dict(receipt)
769
+
522
770
  async def _run(
523
771
  self,
524
772
  instruction: str,
525
773
  environment: BaseEnvironment,
526
774
  context: AgentContext,
775
+ *,
776
+ prepared_phase: PreparedPhase | None = None,
527
777
  ) -> None:
778
+ invocation_started_ns = time.monotonic_ns()
779
+ session = getattr(environment, "_hitch_benchmark", None)
780
+ task_budget_ms = self.hitch_timeout_ms
781
+ if prepared_phase is None and session:
782
+ from hitch_benchmark import candidate_instruction
783
+ instruction, task_timeout = candidate_instruction(instruction, environment)
784
+ if task_timeout is not None:
785
+ task_budget_ms = min(task_timeout, task_budget_ms) if task_budget_ms > 0 else task_timeout
528
786
  self.logs_dir.mkdir(parents=True, exist_ok=True)
529
787
  workdir = self._require_workdir()
530
- assigned_run_id = "run_" + uuid.uuid4().hex
788
+ assigned_run_id = prepared_phase.run_id if prepared_phase else "run_" + uuid.uuid4().hex
531
789
  run_id = assigned_run_id
532
790
  trial_id, task_id, attempt = self._trial_identity()
533
791
  context_payload: dict[str, Any] = {"kind": "ad_hoc"}
534
792
  parent_payload: dict[str, Any] | None = None
535
- if all((self.eval_id, self.benchmark_id, self.benchmark_revision, self.verifier_identity)):
793
+ if prepared_phase is not None:
794
+ context_payload = json.loads(prepared_phase.context_json)
795
+ parent_payload = json.loads(prepared_phase.parent_json)
796
+ elif all((self.eval_id, self.benchmark_id, self.benchmark_revision, self.verifier_identity)):
536
797
  workspace_digest = await self._workspace_digest(environment)
537
798
  task_digest_input = json.dumps(
538
799
  {
@@ -590,9 +851,30 @@ class HitchHarborAgent(BaseAgent):
590
851
  parent_temporary.unlink(missing_ok=True)
591
852
 
592
853
  proxy_environment, proxy_health = await self._model_proxy_environment(environment, run_id)
854
+ if prepared_phase is not None:
855
+ await self._upload_phase_json(environment, self._phase_control_path(prepared_phase), {
856
+ "schema_version": "hitch-phase-control@1", "run_id": run_id, "token": self._phase_control_tokens[run_id],
857
+ })
593
858
  if self._entrypoint is None:
594
859
  raise RuntimeError("Hitch agent setup() must run before run() to resolve the runtime entrypoint")
595
860
  entry = self._remote_entry(self._entrypoint)
861
+ timeout_ms = task_budget_ms
862
+ if prepared_phase is not None:
863
+ timeout_ms = (prepared_phase.deadline_ns - time.monotonic_ns()) // 1_000_000
864
+ if timeout_ms <= 0:
865
+ raise RuntimeError("candidate whole-task budget expired during phase binding/upload")
866
+ elif session:
867
+ preparation_ms = (time.monotonic_ns() - invocation_started_ns) // 1_000_000
868
+ timeout_ms = task_budget_ms - preparation_ms
869
+ (self.logs_dir / "hitch-agent-budget.json").write_text(json.dumps({
870
+ "schema_version": "hitch-agent-budget@1", "run_id": run_id,
871
+ "task_budget_ms": task_budget_ms, "preparation_ms": preparation_ms,
872
+ "hitch_timeout_ms": max(0, timeout_ms),
873
+ "collection_timeout_ms": session.config["profile"]["budget"]["collection_timeout_ms"],
874
+ "scope": "invocation-budget-and-collection-allowance",
875
+ }))
876
+ if timeout_ms <= 0:
877
+ raise RuntimeError("candidate budget expired during input preparation; no model was launched")
596
878
  arguments = [
597
879
  self._node_prefix(),
598
880
  "HITCH_ROOT=/tmp/hitch-state",
@@ -611,7 +893,7 @@ class HitchHarborAgent(BaseAgent):
611
893
  "--context-file",
612
894
  "/tmp/hitch-context.json",
613
895
  "--timeout",
614
- str(self.hitch_timeout_ms),
896
+ str(timeout_ms),
615
897
  "--output",
616
898
  "jsonl",
617
899
  ]
@@ -619,8 +901,11 @@ class HitchHarborAgent(BaseAgent):
619
901
  arguments.extend([
620
902
  "--parent-file", "/tmp/hitch-parent.json",
621
903
  "--internal-run-id", run_id,
622
- "--internal-defer-benchmark-observation",
623
904
  ])
905
+ if prepared_phase is None:
906
+ arguments.append("--internal-defer-benchmark-observation")
907
+ else:
908
+ arguments.extend(["--internal-phase-control", shlex.quote(self._phase_control_path(prepared_phase))])
624
909
  if self.model_name:
625
910
  arguments.extend(["--model", shlex.quote(self.model_name)])
626
911
  for value in self.agent_args:
@@ -634,6 +919,32 @@ class HitchHarborAgent(BaseAgent):
634
919
  + " | tee /logs/agent/hitch-events.jsonl"
635
920
  )
636
921
  execution = await environment.exec(command, cwd=workdir)
922
+ collection = self._collect_run(
923
+ environment, context, execution, assigned_run_id=assigned_run_id,
924
+ context_payload=context_payload, parent_payload=parent_payload, prepared_phase=prepared_phase,
925
+ workdir=workdir, proxy_health=proxy_health,
926
+ )
927
+ if prepared_phase is not None or not session:
928
+ await collection
929
+ return
930
+ try:
931
+ await asyncio.wait_for(collection, session.config["profile"]["budget"]["collection_timeout_ms"] / 1000)
932
+ except asyncio.TimeoutError as error:
933
+ # A completed process with uncollected evidence is still invalid.
934
+ # Do not fabricate a terminal bundle or relabel it as model success.
935
+ receipt = {"code": "hitch_run_collection_timeout", "run_id": assigned_run_id,
936
+ "process_return_code": execution.return_code}
937
+ (self.logs_dir / "hitch-collection-timeout.json").write_text(json.dumps(receipt))
938
+ raise RuntimeError("hitch_run_collection_timeout: terminal evidence export exceeded its allowance") from error
939
+
940
+ async def _collect_run(
941
+ self, environment: BaseEnvironment, context: AgentContext, execution: ExecResult, *,
942
+ assigned_run_id: str, context_payload: dict[str, Any], parent_payload: dict[str, Any] | None,
943
+ prepared_phase: PreparedPhase | None,
944
+ workdir: str, proxy_health: str,
945
+ ) -> None:
946
+ run_id = assigned_run_id
947
+ trial_id, task_id, attempt = self._trial_identity()
637
948
  events = self._events(execution.stdout or "")
638
949
  observed_run_id = next((
639
950
  value
@@ -641,7 +952,7 @@ class HitchHarborAgent(BaseAgent):
641
952
  for value in [event.get("run_id")]
642
953
  if isinstance(value, str) and re.fullmatch(r"run_[a-f0-9]{32}", value)
643
954
  ), None)
644
- if observed_run_id:
955
+ if observed_run_id and prepared_phase is None:
645
956
  run_id = str(observed_run_id)
646
957
  result_path = f"/tmp/hitch-state/runs/{run_id}/result.json"
647
958
  quoted_result_path = shlex.quote(result_path)
@@ -664,7 +975,7 @@ cat -- {quoted_result_path}
664
975
  "trial_id": trial_id,
665
976
  "completed_at": datetime.now(timezone.utc).isoformat(),
666
977
  }, separators=(",", ":"))
667
- bundle_export = await environment.exec(
978
+ legacy_export = (
668
979
  f"""
669
980
  set -eu
670
981
  source_dir={shlex.quote(f'/tmp/hitch-state/runs/{run_id}')}
@@ -680,10 +991,39 @@ rm -rf "$target_dir"
680
991
  mv "$stage_dir" "$target_dir"
681
992
  """.strip()
682
993
  )
994
+ if prepared_phase is None:
995
+ bundle_export = await environment.exec(legacy_export)
996
+ else:
997
+ export_input = {"sourceDirectory": f"/tmp/hitch-state/runs/{run_id}", "destinationDirectory": bundle_stage,
998
+ "expected": {"run_id": run_id, "context": context_payload, "parent": parent_payload,
999
+ "revision_identity": self.revision_identity}}
1000
+ # Completion is outside the bundle: adding even a marker inside a
1001
+ # sealed bundle changes its indexed file set and invalidates it.
1002
+ script = (
1003
+ f"import {{copySealedPhaseRunBundle}} from 'file:///opt/hitch/{PHASE_EXPORT_MODULE}';"
1004
+ "import {open,lstat,rename,writeFile} from 'node:fs/promises';"
1005
+ "const input=JSON.parse(process.argv[1]);"
1006
+ "const target='/logs/agent/hitch-run-bundle';"
1007
+ "const lock=await open('/logs/agent/.hitch-phase-export.lock','wx',0o600);await lock.close();"
1008
+ "try{await lstat(target);throw new Error('phase export target already exists')}catch(e){if(e.code!=='ENOENT')throw e;}"
1009
+ "const index=await copySealedPhaseRunBundle(input);"
1010
+ "await rename(input.destinationDirectory,target);"
1011
+ "await writeFile('/logs/agent/hitch-phase.complete.json',JSON.stringify({schema_version:'1',"
1012
+ "run_id:index.run_id,bundle_digest:index.bundle_digest,scope:'candidate-evidence-only'}),{flag:'wx',mode:0o600});"
1013
+ )
1014
+ bundle_export = await environment.exec(
1015
+ f"{self._node_prefix()} node --input-type=module -e {shlex.quote(script)} {shlex.quote(json.dumps(export_input))}"
1016
+ )
683
1017
  hitch_result, result_error_code, result_error_message = self._parse_hitch_result(result_read, run_id)
1018
+ if prepared_phase is None and hitch_result is not None and getattr(environment, "_hitch_benchmark", None):
1019
+ from hitch_benchmark import export_final_response
1020
+ await export_final_response(environment, hitch_result)
684
1021
  primary_code: str | None = None
685
1022
  primary_message: str | None = None
686
- if execution.return_code != 0:
1023
+ if prepared_phase is not None and observed_run_id and observed_run_id != assigned_run_id:
1024
+ primary_code = "hitch_phase_run_identity_mismatch"
1025
+ primary_message = "Hitch phase emitted a different run ID from its prepared tool binding"
1026
+ elif execution.return_code != 0:
687
1027
  primary_code = "hitch_process_failed"
688
1028
  diagnostic = self._exec_diagnostic(execution)
689
1029
  if hitch_result and isinstance(hitch_result.get("error"), dict):
@@ -711,6 +1051,13 @@ mv "$stage_dir" "$target_dir"
711
1051
  primary_code = "hitch_result_artifact_copy_failed"
712
1052
  primary_message = f"Hitch result artifact copy failed (run_id={run_id}, trial_id={trial_id})"
713
1053
 
1054
+ self._write_trusted_agent_outcome(
1055
+ run_id=run_id,
1056
+ hitch_result=hitch_result,
1057
+ bundle_export=bundle_export,
1058
+ reason_code=primary_code,
1059
+ )
1060
+
714
1061
  context.metadata = {
715
1062
  "candidate_id": self.candidate_id,
716
1063
  "harness_ref": self.harness_ref,
@@ -728,6 +1075,11 @@ mv "$stage_dir" "$target_dir"
728
1075
  "hitch_status": hitch_result.get("status") if hitch_result else None,
729
1076
  "hitch_artifact_id": hitch_result.get("artifact_id") if hitch_result else None,
730
1077
  }
1078
+ if prepared_phase is not None:
1079
+ context.metadata.update(hitch_context_kind="benchmark_phase", hitch_run_group_id=context_payload["run_group_id"],
1080
+ hitch_phase_index=context_payload["phase_index"],
1081
+ hitch_phase_bundle_exported=bundle_export.return_code == 0,
1082
+ hitch_phase_completion="hitch-phase.complete.json")
731
1083
  if self._artifact_manifest is not None:
732
1084
  context.metadata["harness_artifact_transport"] = {
733
1085
  "artifact_id": self._artifact_manifest["artifact_id"],
@@ -945,6 +1297,37 @@ mv "$stage_dir" "$target_dir"
945
1297
  cwd="/",
946
1298
  )
947
1299
 
1300
+ def _write_trusted_agent_outcome(
1301
+ self,
1302
+ *,
1303
+ run_id: str,
1304
+ hitch_result: dict[str, Any] | None,
1305
+ bundle_export: ExecResult,
1306
+ reason_code: str | None,
1307
+ ) -> None:
1308
+ result_status = hitch_result.get("status") if hitch_result else "failed"
1309
+ if result_status not in {"succeeded", "failed", "timed_out", "cancelled"}:
1310
+ result_status = "failed"
1311
+ bundle = "complete" if hitch_result is not None and bundle_export.return_code == 0 else (
1312
+ "missing" if bundle_export.return_code != 0 else "invalid"
1313
+ )
1314
+ outcome = {
1315
+ "schema_version": "1",
1316
+ "run_id": run_id,
1317
+ "status": result_status,
1318
+ "candidate_bundle": bundle,
1319
+ "submission_snapshot": "not-required",
1320
+ "gradeability": "gradeable" if bundle == "complete" else "ungradeable",
1321
+ }
1322
+ if bundle != "complete":
1323
+ outcome["reason_code"] = reason_code or "candidate_evidence_unavailable"
1324
+ self.logs_dir.mkdir(parents=True, exist_ok=True)
1325
+ target = self.logs_dir / HITCH_AGENT_OUTCOME_NAME
1326
+ temporary = target.with_name(f".{target.name}.{uuid.uuid4().hex}.tmp")
1327
+ temporary.write_text(json.dumps(outcome, separators=(",", ":"), sort_keys=True) + "\n", encoding="utf-8")
1328
+ temporary.chmod(0o600)
1329
+ temporary.replace(target)
1330
+
948
1331
  def _trial_identity(self) -> tuple[str, str, int]:
949
1332
  """Read Harbor's stable trial/task identity from the persisted trial state."""
950
1333
  trial_dir = self.logs_dir.parent if self.logs_dir.name == "agent" else self.logs_dir
@@ -1024,43 +1407,158 @@ process.stdout.write('sha256:' + hash.digest('hex'));
1024
1407
  return "sha256:" + hashlib.sha256(b"workspace-unavailable").hexdigest()
1025
1408
 
1026
1409
  async def _ensure_node(self, environment: BaseEnvironment) -> None:
1027
- probe = await environment.exec(
1028
- f"node -e 'process.exit(process.version === \"{self.required_node_version}\" ? 0 : 1)'"
1410
+ """Select a compatible system Node or an authenticated offline runtime.
1411
+
1412
+ No network, package manager, shell profile, or existing system binary
1413
+ is modified here. The archive travels inside the job-pinned artifact,
1414
+ including on remote workers and reruns.
1415
+ """
1416
+ platform = str((self._artifact_manifest or {}).get("platform", ""))
1417
+ self._node_bin_directory = None
1418
+ check = (
1419
+ f"process.exit(process.version === {json.dumps(self.required_node_version)} && "
1420
+ f"process.platform + '-' + process.arch === {json.dumps(platform)} ? 0 : 1)"
1029
1421
  )
1030
- if probe.return_code == 0:
1031
- return
1032
- prerequisites = """
1033
- set -eu
1034
- if command -v curl >/dev/null 2>&1; then exit 0; fi
1035
- if command -v apt-get >/dev/null 2>&1; then
1036
- apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates
1037
- elif command -v apk >/dev/null 2>&1; then
1038
- apk add --no-cache curl ca-certificates bash
1039
- elif command -v dnf >/dev/null 2>&1; then
1040
- dnf install -y curl ca-certificates
1041
- elif command -v yum >/dev/null 2>&1; then
1042
- yum install -y curl ca-certificates
1043
- else
1044
- echo 'Hitch could not install the pinned Node.js runtime in this task image' >&2
1045
- exit 1
1046
- fi
1047
- """
1048
- await self._exec(environment, prerequisites, user=0)
1049
- install = f"""
1050
- set -eu
1051
- export NVM_DIR=/opt/hitch-node
1052
- mkdir -p "$NVM_DIR"
1053
- curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
1054
- . "$NVM_DIR/nvm.sh"
1055
- nvm install {self.required_node_version.removeprefix("v")}
1056
- nvm alias default {self.required_node_version.removeprefix("v")}
1057
- node -e 'process.exit(process.version === "{self.required_node_version}" ? 0 : 1)'
1058
- """
1059
- await self._exec(environment, install, user=0)
1422
+ staging: str | None = None
1423
+ try:
1424
+ probe = await asyncio.wait_for(environment.exec(f"node -e {shlex.quote(check)}"), timeout=30)
1425
+ if probe.return_code == 0:
1426
+ self._node_bin_directory = None
1427
+ self._record_node_runtime({"source": "system", "node_version": self.required_node_version, "platform": platform})
1428
+ return
1429
+ archive, runtime = self._offline_node_runtime()
1430
+ native = await self._node_setup_exec(environment, """set -eu
1431
+ test "$(uname -s)" = Linux
1432
+ case "$(uname -m)" in
1433
+ x86_64|amd64) echo linux-x64 ;;
1434
+ aarch64|arm64) echo linux-arm64 ;;
1435
+ *) exit 1 ;;
1436
+ esac""", "hitch_node_runtime_incompatible")
1437
+ if (native.stdout or "").strip() != runtime["platform"]:
1438
+ raise self._node_runtime_error("hitch_node_runtime_incompatible", "offline Node architecture does not match the task container")
1439
+ libc = await self._node_setup_exec(environment, "getconf GNU_LIBC_VERSION", "hitch_node_runtime_incompatible")
1440
+ if not re.fullmatch(r"glibc \d+\.\d+", (libc.stdout or "").strip()):
1441
+ raise self._node_runtime_error("hitch_node_runtime_incompatible", "offline Node requires a glibc task image; musl is not supported")
1442
+ await self._node_setup_exec(
1443
+ environment, "command -v sha256sum && command -v tar && command -v gzip", "hitch_node_runtime_prerequisite_missing",
1444
+ )
1445
+ # Unique per setup; never extract over a system Node or an old,
1446
+ # partially installed runtime. The ready directory appears only
1447
+ # after checksum, extraction and executable compatibility checks.
1448
+ target = f"/opt/hitch-node-runtime-{uuid.uuid4().hex}"
1449
+ await self._node_setup_exec(environment, f"mkdir -m 755 {target}", "hitch_node_runtime_install_failed")
1450
+ staging = target
1451
+ await asyncio.wait_for(environment.upload_file(archive, f"{staging}/node-runtime.tar.gz"), timeout=120)
1452
+ checksum = runtime["archive_sha256"].removeprefix("sha256:")
1453
+ await self._node_setup_exec(
1454
+ environment,
1455
+ f"cd {staging} && printf '%s\\n' '{checksum} node-runtime.tar.gz' | sha256sum -c -",
1456
+ "hitch_node_runtime_integrity_mismatch",
1457
+ )
1458
+ await self._node_setup_exec(
1459
+ environment,
1460
+ f"umask 022; mkdir -m 755 {staging}/unpacked && tar --no-same-owner -xzf {staging}/node-runtime.tar.gz -C {staging}/unpacked",
1461
+ "hitch_node_runtime_install_failed",
1462
+ )
1463
+ await self._node_setup_exec(
1464
+ environment, f"{staging}/unpacked/bin/node -e {shlex.quote(check)}", "hitch_node_runtime_incompatible",
1465
+ )
1466
+ await self._node_setup_exec(
1467
+ environment, f"mv {staging}/unpacked {staging}/ready && rm {staging}/node-runtime.tar.gz", "hitch_node_runtime_install_failed",
1468
+ )
1469
+ self._node_bin_directory = f"{staging}/ready/bin"
1470
+ self._record_node_runtime({"source": "offline-artifact", **runtime, "bin_directory": self._node_bin_directory})
1471
+ staging = None
1472
+ except Exception as error:
1473
+ failure = error if isinstance(error, HitchBridgeError) else self._node_runtime_error(
1474
+ "hitch_node_runtime_setup_failed", f"{type(error).__name__}: {error}",
1475
+ )
1476
+ self._record_node_runtime({"source": "failed", **failure.evidence})
1477
+ try:
1478
+ await self._write_bridge_error(environment, failure.evidence)
1479
+ except Exception:
1480
+ pass # Preserve the original Node failure even if log export fails.
1481
+ if failure is error:
1482
+ raise
1483
+ raise failure from error
1484
+ finally:
1485
+ if staging is not None:
1486
+ # Only the random directory created by this setup is removed.
1487
+ try:
1488
+ await asyncio.wait_for(environment.exec(f"rm -rf -- {staging}", user=0), timeout=30)
1489
+ except Exception:
1490
+ pass
1060
1491
 
1061
- @staticmethod
1062
- def _node_prefix() -> str:
1063
- return "if [ -s /opt/hitch-node/nvm.sh ]; then export NVM_DIR=/opt/hitch-node; . /opt/hitch-node/nvm.sh; fi;"
1492
+ def _offline_node_runtime(self) -> tuple[Path, dict[str, Any]]:
1493
+ directory = self._artifact_host_directory
1494
+ if directory is None or not (directory / ".hitch-node-runtime").exists():
1495
+ raise self._node_runtime_error(
1496
+ "hitch_node_runtime_missing",
1497
+ "task has no matching Node and the pinned artifact has no offline runtime; prepare a new eval with the updated controller (no online fallback)",
1498
+ )
1499
+ try:
1500
+ # Authenticate the runtime metadata against the job's CONTENT pin,
1501
+ # not a self-reported checksum in a mutable sidecar. This is needed
1502
+ # before Node can run Hitch's normal in-container artifact check.
1503
+ captured = {".hitch-node-runtime/node-runtime.json": b""}
1504
+ if artifact_directory_integrity(directory, captured) != (self.harness_artifact or {}).get("artifact_integrity"):
1505
+ raise RuntimeError("job-pinned harness content digest mismatch before Node bootstrap")
1506
+ bundle = directory / ".hitch-node-runtime"
1507
+ if bundle.is_symlink() or not bundle.is_dir():
1508
+ raise RuntimeError("offline Node bundle must be a regular directory")
1509
+ manifest_path = bundle / "node-runtime.json"
1510
+ self._assert_regular_host_file(manifest_path, "offline Node manifest", 16_384)
1511
+ # Parse exactly the bytes hashed above, not a second sidecar read
1512
+ # which could race a cache mutation after content authentication.
1513
+ runtime = json.loads(captured[".hitch-node-runtime/node-runtime.json"].decode("utf-8"))
1514
+ fields = {"schema_version", "recipe_version", "runtime_id", "node_version", "platform", "libc", "builder_image_id", "archive_sha256", "archive_bytes"}
1515
+ if not isinstance(runtime, dict) or set(runtime) != fields:
1516
+ raise RuntimeError("offline Node manifest fields are invalid")
1517
+ payload = {key: value for key, value in runtime.items() if key != "runtime_id"}
1518
+ identity = "sha256:" + hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
1519
+ if runtime["schema_version"] != "1" or runtime["recipe_version"] != "1" or runtime["runtime_id"] != identity:
1520
+ raise RuntimeError("offline Node runtime identity is invalid")
1521
+ if runtime["node_version"] != self.required_node_version or runtime["platform"] != (self._artifact_manifest or {}).get("platform") or runtime["libc"] != "glibc":
1522
+ raise RuntimeError("offline Node manifest does not match the job runtime contract")
1523
+ for field in ("archive_sha256", "builder_image_id"):
1524
+ if not isinstance(runtime[field], str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", runtime[field]):
1525
+ raise RuntimeError(f"offline Node {field} is invalid")
1526
+ archive = bundle / "node-runtime.tar.gz"
1527
+ info = self._assert_regular_host_file(archive, "offline Node archive", 128 * 1024 * 1024)
1528
+ if type(runtime["archive_bytes"]) is not int or info.st_size != runtime["archive_bytes"] or info.st_size <= 0:
1529
+ raise RuntimeError("offline Node archive size mismatch")
1530
+ archive_digest = hashlib.sha256()
1531
+ with archive.open("rb") as handle:
1532
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
1533
+ archive_digest.update(chunk)
1534
+ if "sha256:" + archive_digest.hexdigest() != runtime["archive_sha256"]:
1535
+ raise RuntimeError("offline Node archive checksum mismatch")
1536
+ return archive, runtime
1537
+ except Exception as error:
1538
+ raise self._node_runtime_error("hitch_node_runtime_integrity_mismatch", str(error)) from error
1539
+
1540
+ async def _node_setup_exec(self, environment: BaseEnvironment, command: str, code: str) -> ExecResult:
1541
+ result = await asyncio.wait_for(environment.exec(command, user=0), timeout=60)
1542
+ if result.return_code != 0:
1543
+ raise self._node_runtime_error(code, f"exit={result.return_code}: {self._exec_diagnostic(result)}")
1544
+ return result
1545
+
1546
+ def _node_runtime_error(self, code: str, message: str) -> HitchBridgeError:
1547
+ return HitchBridgeError(code, self._bounded_tail(message, 2048), {
1548
+ "schema_version": "1", "code": code, "message": self._bounded_tail(message, 2048),
1549
+ "eval_id": self.eval_id, "node_version": self.required_node_version,
1550
+ "platform": (self._artifact_manifest or {}).get("platform"),
1551
+ "artifact_id": (self.harness_artifact or {}).get("artifact_id"),
1552
+ })
1553
+
1554
+ def _record_node_runtime(self, evidence: dict[str, Any]) -> None:
1555
+ self.logs_dir.mkdir(parents=True, exist_ok=True)
1556
+ (self.logs_dir / "hitch-node-runtime.json").write_text(
1557
+ json.dumps({"schema_version": "1", **evidence}, indent=2, sort_keys=True) + "\n", encoding="utf-8",
1558
+ )
1559
+
1560
+ def _node_prefix(self) -> str:
1561
+ return f"export PATH={shlex.quote(self._node_bin_directory)}:\"$PATH\";" if self._node_bin_directory else ""
1064
1562
 
1065
1563
  @staticmethod
1066
1564
  def _events(output: str) -> list[dict[str, Any]]: