agent-hitch 0.2.7 → 0.2.9

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 +12 -2
  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 +42 -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 +73 -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 +30 -74
  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 +666 -68
  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:
@@ -163,8 +241,7 @@ class HitchHarborAgent(BaseAgent):
163
241
  # bookkeeping and is not identity (spec §4.2).
164
242
  await environment.upload_dir(payload_dir, "/opt/hitch")
165
243
  await self._ensure_node(environment)
166
- platform = await self._container_platform(environment)
167
- node_version = await self._container_node_version(environment)
244
+ platform, node_version = await self._container_node_identity(environment)
168
245
  if self._artifact_manifest is None or not self._artifact_compatible(
169
246
  self._artifact_manifest, platform, node_version
170
247
  ):
@@ -369,22 +446,43 @@ class HitchHarborAgent(BaseAgent):
369
446
  raise RuntimeError(f"{label} exceeds the size limit ({maximum} bytes)")
370
447
  return info
371
448
 
372
- async def _container_platform(self, environment: BaseEnvironment) -> str:
373
- result = await self._exec(
374
- environment,
375
- f'{self._node_prefix()} node -p "process.platform + \'-\' + process.arch"',
449
+ async def _container_node_identity(self, environment: BaseEnvironment) -> tuple[str, str]:
450
+ # Harbor may merge stderr into stdout. Frame only the machine value so
451
+ # startup warnings cannot be mistaken for the platform or version.
452
+ marker = "__HITCH_NODE_IDENTITY__"
453
+ script = (
454
+ f"process.stdout.write('\\n{marker}' + process.platform + '-' + "
455
+ "process.arch + ' ' + process.version + '\\n')"
456
+ )
457
+ command = f"{self._node_prefix()} node -e {shlex.quote(script)}"
458
+ result = await environment.exec(command)
459
+ lines = [line for line in (result.stdout or "").splitlines() if line.startswith(marker)]
460
+ identity = re.fullmatch(
461
+ re.escape(marker) + r"((?:linux|darwin|win32)-[a-z0-9_]+) (v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)",
462
+ lines[0],
463
+ ) if len(lines) == 1 else None
464
+ if result.return_code == 0 and identity is not None:
465
+ return identity.group(1), identity.group(2)
466
+
467
+ reason = f"exit={result.return_code}" if result.return_code != 0 else "expected one valid identity marker"
468
+ failure = self._node_runtime_error(
469
+ "hitch_node_runtime_identity_invalid",
470
+ f"container returned an invalid Node.js identity ({reason}); "
471
+ f"stdout={self._bounded_tail(result.stdout or '', 512)!r}; "
472
+ f"stderr={self._bounded_tail(result.stderr or '', 512)!r}",
376
473
  )
377
- platform = (result.stdout or "").strip()
378
- if not re.fullmatch(r"(?:linux|darwin|win32)-[a-z0-9_]+", platform):
379
- raise RuntimeError("container returned an invalid Node.js platform identity")
380
- return platform
381
-
382
- async def _container_node_version(self, environment: BaseEnvironment) -> str:
383
- result = await self._exec(environment, f'{self._node_prefix()} node -p "process.version"')
384
- version = (result.stdout or "").strip()
385
- if not re.fullmatch(r"v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", version):
386
- raise RuntimeError("container returned an invalid Node.js version")
387
- return version
474
+ failure.evidence["probe"] = {
475
+ "command": command,
476
+ "return_code": result.return_code,
477
+ "stdout_tail": self._bounded_tail(result.stdout or ""),
478
+ "stderr_tail": self._bounded_tail(result.stderr or ""),
479
+ }
480
+ self._record_node_runtime({"source": "failed", "bin_directory": self._node_bin_directory, **failure.evidence})
481
+ try:
482
+ await self._write_bridge_error(environment, failure.evidence)
483
+ except Exception:
484
+ pass # Keep the probe failure if the container cannot export logs.
485
+ raise failure
388
486
 
389
487
  @staticmethod
390
488
  def _artifact_compatible(manifest: dict[str, Any], platform: str, node_version: str) -> bool:
@@ -513,26 +611,209 @@ class HitchHarborAgent(BaseAgent):
513
611
  environment: BaseEnvironment,
514
612
  context: AgentContext,
515
613
  ) -> None:
614
+ session = getattr(environment, "_hitch_benchmark", None)
615
+ config = session.config if session else None
616
+ driver = config["task"]["driver"] if config else None
617
+ phases = driver["config"].get("native_phases") if driver and driver["kind"] == "tool-server" else None
618
+ if phases:
619
+ from hitch_phase_supervisor import NativePhaseSupervisor
620
+ task_timeout = int(config["agent_timeout_sec"] * 1000)
621
+ remaining = min(task_timeout, self.hitch_timeout_ms) if self.hitch_timeout_ms > 0 else task_timeout
622
+ result = await NativePhaseSupervisor(
623
+ self, environment, controller={"service": driver["config"]["service"], "argv": phases["argv"]},
624
+ binding={"endpoint": driver["config"]["endpoint"], "tools": config["tools"]},
625
+ task_digest=config["task_digest"], timeout_ms=remaining,
626
+ shutdown_timeout_ms=phases["shutdown_timeout_ms"],
627
+ finalization_timeout_ms=phases.get("finalization_timeout_ms"),
628
+ task_instruction=instruction,
629
+ ).run()
630
+ context.metadata = {"candidate_id": self.candidate_id, "harness_ref": self.harness_ref,
631
+ "revision_identity": self.revision_identity, "controller_runtime_id": self.controller_runtime_id,
632
+ "hitch_context_kind": "benchmark_phase_group", "hitch_run_group_id": result["run_group_id"],
633
+ "hitch_phase_count": len(result["phases"]), "hitch_status": "succeeded",
634
+ "hitch_phase_supervision": "hitch-native-phases/supervision.json"}
635
+ return
516
636
  started_ns = time.monotonic_ns()
517
637
  try:
518
638
  await self._run(instruction, environment, context)
519
639
  finally:
520
640
  self._write_phase_timing("agent", started_ns)
521
641
 
642
+ def _phase_identity(self) -> str:
643
+ identity = [self.candidate_id, self.harness_ref, self.revision_identity, self.controller_runtime_id,
644
+ self.model_name, self.agent_args, self.credential_names, self._require_workdir(),
645
+ self.eval_id, self.benchmark_id, self.benchmark_revision, self.verifier_identity, self._trial_identity()]
646
+ return hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest()
647
+
648
+ def prepare_phase(self, *, instruction: str, run_group_id: str, phase_index: int,
649
+ task_digest: str, remaining_timeout_ms: int) -> PreparedPhase:
650
+ """Reserve identity before private tool binding; does not start a model.
651
+
652
+ The supervisor supplies one frozen task digest across all phases and a
653
+ remaining whole-task budget. Time spent binding/uploading consumes that
654
+ budget too. The returned immutable handle is single-use on this agent.
655
+ """
656
+ if not self._setup_complete or not self._phase_export_available:
657
+ raise RuntimeError("phase preparation requires setup with a phase-export capable runtime")
658
+ if self._prepared_phase is not None or self._phase_inflight:
659
+ raise RuntimeError("another candidate phase is already prepared or running")
660
+ if (not isinstance(instruction, str) or not instruction.strip()
661
+ or not isinstance(run_group_id, str) or not re.fullmatch(r"run_group_[a-f0-9]{32}", run_group_id)
662
+ or type(phase_index) is not int or not 1 <= phase_index <= 10000
663
+ or not isinstance(task_digest, str) or not re.fullmatch(r"sha256:[a-f0-9]{64}", task_digest)
664
+ or type(remaining_timeout_ms) is not int or not 1 <= remaining_timeout_ms <= 9007199254740991):
665
+ raise ValueError("invalid candidate phase identity or remaining budget")
666
+ if not all((self.eval_id, self.benchmark_id, self.benchmark_revision, self.verifier_identity)):
667
+ raise ValueError("candidate phases require a complete eval and benchmark identity")
668
+ if (not re.fullmatch(r"eval_[a-f0-9]{32}", self.eval_id)
669
+ or any(not re.fullmatch(r"sha256:[a-f0-9]{64}", value) for value in (self.benchmark_revision, self.verifier_identity))):
670
+ raise ValueError("candidate phase eval and benchmark identities must be immutable")
671
+ key = (run_group_id, phase_index)
672
+ if key in self._prepared_phase_keys:
673
+ raise RuntimeError("candidate phase identity was already prepared; implicit retries are forbidden")
674
+ identity = self._phase_identity()
675
+ previous = self._phase_group_contracts.get(run_group_id)
676
+ if (previous is None and phase_index != 1
677
+ or previous is not None and previous != (task_digest, identity, phase_index - 1)):
678
+ raise RuntimeError("candidate phase group must retain its task/candidate identity and consecutive indices")
679
+ trial_id, task_id, attempt = self._trial_identity()
680
+ context = {"kind": "benchmark_phase", "benchmark_id": self.benchmark_id,
681
+ "benchmark_revision": self.benchmark_revision, "task_id": task_id,
682
+ "task_digest": task_digest, "verifier_identity": self.verifier_identity,
683
+ "run_group_id": run_group_id, "phase_index": phase_index}
684
+ parent = {"kind": "eval", "eval_id": self.eval_id, "trial_id": trial_id, "attempt": attempt}
685
+ prepared = PreparedPhase("run_" + uuid.uuid4().hex, instruction, json.dumps(context, sort_keys=True),
686
+ json.dumps(parent, sort_keys=True), identity,
687
+ time.monotonic_ns() + remaining_timeout_ms * 1_000_000)
688
+ self._phase_control_tokens[prepared.run_id] = secrets.token_hex(32)
689
+ self._prepared_phase_keys.add(key)
690
+ self._phase_group_contracts[run_group_id] = (task_digest, identity, phase_index)
691
+ self._prepared_phase = prepared
692
+ return prepared
693
+
694
+ async def run_phase(self, prepared: PreparedPhase, environment: BaseEnvironment, context: AgentContext) -> None:
695
+ if not isinstance(prepared, PreparedPhase) or self._prepared_phase is not prepared or self._phase_inflight:
696
+ raise RuntimeError("candidate phase handle is stale, foreign, or already consumed")
697
+ self._prepared_phase = None # Every outcome consumes the handle.
698
+ if prepared.identity != self._phase_identity():
699
+ self._phase_control_tokens.pop(prepared.run_id, None)
700
+ raise RuntimeError("candidate identity changed after phase preparation")
701
+ if prepared.deadline_ns <= time.monotonic_ns():
702
+ self._phase_control_tokens.pop(prepared.run_id, None)
703
+ raise RuntimeError("candidate whole-task budget expired before phase start")
704
+ self._phase_inflight = True
705
+ self._active_phase = prepared
706
+ started_ns = time.monotonic_ns()
707
+ try:
708
+ await self._run(prepared.instruction, environment, context, prepared_phase=prepared)
709
+ finally:
710
+ self._phase_inflight = False
711
+ self._active_phase = None
712
+ self._phase_control_tokens.pop(prepared.run_id, None)
713
+ self._write_phase_timing("agent", started_ns)
714
+
715
+ @staticmethod
716
+ def _phase_control_path(prepared: PreparedPhase) -> str:
717
+ return f"/tmp/hitch-phase-control-{prepared.run_id}.config.json"
718
+
719
+ @staticmethod
720
+ async def _upload_phase_json(environment: BaseEnvironment, target: str, value: dict[str, Any]) -> None:
721
+ # Keep control nonces out of both argv and the mounted agent log tree.
722
+ with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", prefix="hitch-phase-control-", delete=False) as handle:
723
+ json.dump(value, handle)
724
+ temporary = Path(handle.name)
725
+ try:
726
+ await environment.upload_file(temporary, target)
727
+ result = await environment.exec(f"chmod 600 {shlex.quote(target)}")
728
+ if result.return_code != 0:
729
+ raise RuntimeError("could not protect candidate phase control input")
730
+ finally:
731
+ temporary.unlink(missing_ok=True)
732
+
733
+ async def request_phase_cancellation(self, prepared: PreparedPhase, environment: BaseEnvironment, *, reason: str) -> dict[str, Any]:
734
+ """Request executor cancellation; await run_phase separately for sealed evidence.
735
+
736
+ This receipt proves only that the request was delivered, not that the
737
+ model stopped or that a native phase completed. Await this operation
738
+ before recycling the candidate. A caller must separately enforce a
739
+ bounded shutdown/collection allowance and whole-trial failure cleanup.
740
+ """
741
+ if reason not in {"native_phase_reset", "native_task_finished", "task_budget_expired", "cancelled"}:
742
+ raise ValueError("invalid phase cancellation reason")
743
+ if self._phase_cancel_lock is None:
744
+ self._phase_cancel_lock = asyncio.Lock()
745
+ async with self._phase_cancel_lock:
746
+ if not isinstance(prepared, PreparedPhase) or self._active_phase is not prepared:
747
+ raise RuntimeError("candidate phase is not active")
748
+ existing = self._phase_cancel_receipts.get(prepared.run_id)
749
+ if existing is not None:
750
+ if existing["reason"] != reason:
751
+ raise RuntimeError("candidate phase cancellation reason already fixed")
752
+ if existing["status"] != "delivered":
753
+ raise RuntimeError("candidate cancellation delivery is incomplete; implicit retries are forbidden")
754
+ return dict(existing)
755
+ phase = json.loads(prepared.context_json)
756
+ record_dir = self.logs_dir.parent / "hitch-phase-control"
757
+ record_dir.mkdir(mode=0o700, exist_ok=True)
758
+ if record_dir.is_symlink() or record_dir.stat().st_mode & 0o077:
759
+ raise RuntimeError("candidate cancellation records require a private host directory")
760
+ record_path = record_dir / f"{prepared.run_id}.request.json"
761
+ receipt = {"schema_version": "hitch-phase-cancel-request@1", "scope": "request-only",
762
+ "status": "prepared",
763
+ "request_id": "phase_cancel_" + uuid.uuid4().hex, "run_id": prepared.run_id,
764
+ "run_group_id": phase["run_group_id"], "phase_index": phase["phase_index"],
765
+ "reason": reason, "requested_at": datetime.now(timezone.utc).isoformat(),
766
+ "record_ref": f"hitch-phase-control/{record_path.name}"}
767
+ with record_path.open("x", encoding="utf-8") as handle:
768
+ json.dump(receipt, handle, sort_keys=True)
769
+ handle.write("\n")
770
+ record_path.chmod(0o600)
771
+ self._phase_cancel_receipts[prepared.run_id] = receipt
772
+ try:
773
+ await self._upload_phase_json(environment, self._phase_control_path(prepared).replace(".config.json", ".request.json"), {
774
+ "schema_version": "hitch-phase-cancel@1", "run_id": prepared.run_id,
775
+ "token": self._phase_control_tokens[prepared.run_id], "reason": reason,
776
+ })
777
+ receipt["status"] = "delivered"
778
+ except BaseException as error:
779
+ receipt.update(status="delivery_failed", failure_type=type(error).__name__)
780
+ raise
781
+ finally:
782
+ update = record_path.with_suffix(".pending")
783
+ with update.open("x", encoding="utf-8") as handle:
784
+ json.dump(receipt, handle, sort_keys=True)
785
+ handle.write("\n")
786
+ update.chmod(0o600)
787
+ update.replace(record_path)
788
+ return dict(receipt)
789
+
522
790
  async def _run(
523
791
  self,
524
792
  instruction: str,
525
793
  environment: BaseEnvironment,
526
794
  context: AgentContext,
795
+ *,
796
+ prepared_phase: PreparedPhase | None = None,
527
797
  ) -> None:
798
+ invocation_started_ns = time.monotonic_ns()
799
+ session = getattr(environment, "_hitch_benchmark", None)
800
+ task_budget_ms = self.hitch_timeout_ms
801
+ if prepared_phase is None and session:
802
+ from hitch_benchmark import candidate_instruction
803
+ instruction, task_timeout = candidate_instruction(instruction, environment)
804
+ if task_timeout is not None:
805
+ task_budget_ms = min(task_timeout, task_budget_ms) if task_budget_ms > 0 else task_timeout
528
806
  self.logs_dir.mkdir(parents=True, exist_ok=True)
529
807
  workdir = self._require_workdir()
530
- assigned_run_id = "run_" + uuid.uuid4().hex
808
+ assigned_run_id = prepared_phase.run_id if prepared_phase else "run_" + uuid.uuid4().hex
531
809
  run_id = assigned_run_id
532
810
  trial_id, task_id, attempt = self._trial_identity()
533
811
  context_payload: dict[str, Any] = {"kind": "ad_hoc"}
534
812
  parent_payload: dict[str, Any] | None = None
535
- if all((self.eval_id, self.benchmark_id, self.benchmark_revision, self.verifier_identity)):
813
+ if prepared_phase is not None:
814
+ context_payload = json.loads(prepared_phase.context_json)
815
+ parent_payload = json.loads(prepared_phase.parent_json)
816
+ elif all((self.eval_id, self.benchmark_id, self.benchmark_revision, self.verifier_identity)):
536
817
  workspace_digest = await self._workspace_digest(environment)
537
818
  task_digest_input = json.dumps(
538
819
  {
@@ -590,14 +871,35 @@ class HitchHarborAgent(BaseAgent):
590
871
  parent_temporary.unlink(missing_ok=True)
591
872
 
592
873
  proxy_environment, proxy_health = await self._model_proxy_environment(environment, run_id)
874
+ if prepared_phase is not None:
875
+ await self._upload_phase_json(environment, self._phase_control_path(prepared_phase), {
876
+ "schema_version": "hitch-phase-control@1", "run_id": run_id, "token": self._phase_control_tokens[run_id],
877
+ })
593
878
  if self._entrypoint is None:
594
879
  raise RuntimeError("Hitch agent setup() must run before run() to resolve the runtime entrypoint")
595
880
  entry = self._remote_entry(self._entrypoint)
881
+ timeout_ms = task_budget_ms
882
+ if prepared_phase is not None:
883
+ timeout_ms = (prepared_phase.deadline_ns - time.monotonic_ns()) // 1_000_000
884
+ if timeout_ms <= 0:
885
+ raise RuntimeError("candidate whole-task budget expired during phase binding/upload")
886
+ elif session:
887
+ preparation_ms = (time.monotonic_ns() - invocation_started_ns) // 1_000_000
888
+ timeout_ms = task_budget_ms - preparation_ms
889
+ (self.logs_dir / "hitch-agent-budget.json").write_text(json.dumps({
890
+ "schema_version": "hitch-agent-budget@1", "run_id": run_id,
891
+ "task_budget_ms": task_budget_ms, "preparation_ms": preparation_ms,
892
+ "hitch_timeout_ms": max(0, timeout_ms),
893
+ "collection_timeout_ms": session.config["profile"]["budget"]["collection_timeout_ms"],
894
+ "scope": "invocation-budget-and-collection-allowance",
895
+ }))
896
+ if timeout_ms <= 0:
897
+ raise RuntimeError("candidate budget expired during input preparation; no model was launched")
596
898
  arguments = [
597
899
  self._node_prefix(),
598
900
  "HITCH_ROOT=/tmp/hitch-state",
599
901
  *proxy_environment,
600
- *(["HITCH_HARBOR_INTERNAL=1"] if self._artifact_uploaded or parent_payload is not None else []),
902
+ "HITCH_HARBOR_INTERNAL=1",
601
903
  f"node {entry} run",
602
904
  "--harness",
603
905
  shlex.quote(self.harness_ref),
@@ -611,29 +913,138 @@ class HitchHarborAgent(BaseAgent):
611
913
  "--context-file",
612
914
  "/tmp/hitch-context.json",
613
915
  "--timeout",
614
- str(self.hitch_timeout_ms),
916
+ str(timeout_ms),
615
917
  "--output",
616
918
  "jsonl",
919
+ "--internal-run-id", run_id,
617
920
  ]
618
921
  if parent_payload is not None:
619
922
  arguments.extend([
620
923
  "--parent-file", "/tmp/hitch-parent.json",
621
- "--internal-run-id", run_id,
622
- "--internal-defer-benchmark-observation",
623
924
  ])
925
+ if prepared_phase is None:
926
+ arguments.append("--internal-defer-benchmark-observation")
927
+ else:
928
+ arguments.extend(["--internal-phase-control", shlex.quote(self._phase_control_path(prepared_phase))])
624
929
  if self.model_name:
625
930
  arguments.extend(["--model", shlex.quote(self.model_name)])
626
931
  for value in self.agent_args:
627
932
  arguments.extend(["--agent-arg", shlex.quote(value)])
628
933
  for name in self.credential_names:
629
934
  arguments.extend(["--internal-credential-name", shlex.quote(name)])
630
- command = (
631
- "set -o pipefail; "
632
- + " ".join(arguments)
633
- + " 2> >(tee /logs/agent/hitch-stderr.log >&2)"
634
- + " | tee /logs/agent/hitch-events.jsonl"
935
+ command = self._logged_run_command(" ".join(arguments))
936
+ try:
937
+ execution = await environment.exec(command, cwd=workdir)
938
+ except (Exception, asyncio.CancelledError):
939
+ # Harbor cancels this await on its agent deadline. Collect what is
940
+ # already durable before it removes the container, without turning
941
+ # a cancelled execution into a successful observation.
942
+ await self._preserve_interrupted_run(environment, assigned_run_id)
943
+ raise
944
+ collection = self._collect_run(
945
+ environment, context, execution, assigned_run_id=assigned_run_id,
946
+ context_payload=context_payload, parent_payload=parent_payload, prepared_phase=prepared_phase,
947
+ workdir=workdir, proxy_health=proxy_health,
635
948
  )
636
- execution = await environment.exec(command, cwd=workdir)
949
+ if prepared_phase is not None or not session:
950
+ await collection
951
+ return
952
+ try:
953
+ await asyncio.wait_for(collection, session.config["profile"]["budget"]["collection_timeout_ms"] / 1000)
954
+ except asyncio.TimeoutError as error:
955
+ # A completed process with uncollected evidence is still invalid.
956
+ # Do not fabricate a terminal bundle or relabel it as model success.
957
+ receipt = {"code": "hitch_run_collection_timeout", "run_id": assigned_run_id,
958
+ "process_return_code": execution.return_code}
959
+ (self.logs_dir / "hitch-collection-timeout.json").write_text(json.dumps(receipt))
960
+ raise RuntimeError("hitch_run_collection_timeout: terminal evidence export exceeded its allowance") from error
961
+
962
+ @staticmethod
963
+ def _logged_run_command(invocation: str) -> str:
964
+ # Bash process substitution leaves an extra pipe FD in the harness and
965
+ # its descendants. A task's background service may outlive the harness.
966
+ # Regular files keep live logs without tying exec completion to that FD.
967
+ return (
968
+ "hitch_run_exit=0; " + invocation
969
+ + " > /logs/agent/hitch-events.jsonl 2> /logs/agent/hitch-stderr.log || hitch_run_exit=$?; "
970
+ + "cat /logs/agent/hitch-events.jsonl || exit $?; "
971
+ + "cat /logs/agent/hitch-stderr.log >&2 || exit $?; "
972
+ + 'exit "$hitch_run_exit"'
973
+ )
974
+
975
+ async def _preserve_interrupted_run(self, environment: BaseEnvironment, run_id: str) -> None:
976
+ # This is a diagnostic directory, never a complete/sealed run bundle.
977
+ # runtime-home contains credentials and must not be copied. Only files
978
+ # already produced by Hitch's redacted evidence writers are eligible.
979
+ destination = f"/logs/agent/hitch-interrupted-run-{uuid.uuid4().hex}"
980
+ script = r"""
981
+ import {lstat,mkdir,open,readFile,writeFile} from 'node:fs/promises';
982
+ import {constants} from 'node:fs';
983
+ import path from 'node:path';
984
+ const [source,target]=process.argv.slice(1);
985
+ let remaining=64*1024*1024;
986
+ const copied=[];
987
+ await mkdir(target,{mode:0o700});
988
+ async function copy(relative){
989
+ if(copied.includes(relative))return;
990
+ if(typeof relative!=='string'||relative.includes('\\')||path.isAbsolute(relative)
991
+ ||relative.split('/').some(p=>!p||p==='.'||p==='..')) return;
992
+ const file=path.join(source,relative);
993
+ try {
994
+ for(let p=path.dirname(file);p!=='/';p=path.dirname(p)) {
995
+ const info=await lstat(p); if(!info.isDirectory()||info.isSymbolicLink()) return;
996
+ }
997
+ if(!(await lstat(file)).isFile())return;
998
+ const input=await open(file,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);
999
+ try {
1000
+ const info=await input.stat();
1001
+ if(!info.isFile()||info.nlink!==1||info.size>remaining) return;
1002
+ const bytes=Buffer.alloc(info.size);
1003
+ let offset=0;
1004
+ while(offset<bytes.length){const r=await input.read(bytes,offset,bytes.length-offset,offset);if(!r.bytesRead)break;offset+=r.bytesRead;}
1005
+ remaining-=offset;
1006
+ const output=path.join(target,relative);
1007
+ await mkdir(path.dirname(output),{recursive:true,mode:0o700});
1008
+ await writeFile(output,bytes.subarray(0,offset),{flag:'wx',mode:0o600});
1009
+ copied.push(relative);
1010
+ } finally {await input.close();}
1011
+ } catch(e) {if(!['ENOENT','ELOOP','ENOTDIR'].includes(e.code))throw e;}
1012
+ }
1013
+ for(const name of ['result.json','manifest.json','events.jsonl','stdout.log','stderr.log','trajectory.ref.json']) await copy(name);
1014
+ if(copied.includes('trajectory.ref.json')) {
1015
+ try {
1016
+ const ref=JSON.parse(await readFile(path.join(target,'trajectory.ref.json'),'utf8'));
1017
+ const files=Array.isArray(ref.files)?ref.files.map(f=>f?.path):[ref.path];
1018
+ for(const file of files.slice(0,256)) if(typeof file==='string'&&file.startsWith('trajectory/'))await copy(file);
1019
+ } catch(e) {if(!(e instanceof SyntaxError))throw e;}
1020
+ }
1021
+ // ProviderCaptureWriter persists redacted output before finalization creates the reference.
1022
+ for(const name of ['trajectory/provider/events.jsonl','trajectory/provider/transcript.txt']) await copy(name);
1023
+ await writeFile(path.join(target,'diagnostic.json'),JSON.stringify({complete:false,copied}),{flag:'wx',mode:0o600});
1024
+ """.strip()
1025
+ receipt: dict[str, Any] = {"run_id": run_id, "complete": False, "directory": destination}
1026
+ try:
1027
+ result = await asyncio.wait_for(environment.exec(
1028
+ f"{self._node_prefix()} node --input-type=module -e {shlex.quote(script)} "
1029
+ f"{shlex.quote(f'/tmp/hitch-state/runs/{run_id}')} {shlex.quote(destination)}"
1030
+ ), timeout=5)
1031
+ receipt["export_return_code"] = result.return_code
1032
+ except (Exception, asyncio.CancelledError) as error:
1033
+ # Preserve the original exception, not a best-effort export failure.
1034
+ receipt["export_error"] = type(error).__name__
1035
+ try:
1036
+ (self.logs_dir / "hitch-interrupted-run.json").write_text(json.dumps(receipt) + "\n")
1037
+ except OSError:
1038
+ pass
1039
+
1040
+ async def _collect_run(
1041
+ self, environment: BaseEnvironment, context: AgentContext, execution: ExecResult, *,
1042
+ assigned_run_id: str, context_payload: dict[str, Any], parent_payload: dict[str, Any] | None,
1043
+ prepared_phase: PreparedPhase | None,
1044
+ workdir: str, proxy_health: str,
1045
+ ) -> None:
1046
+ run_id = assigned_run_id
1047
+ trial_id, task_id, attempt = self._trial_identity()
637
1048
  events = self._events(execution.stdout or "")
638
1049
  observed_run_id = next((
639
1050
  value
@@ -641,7 +1052,7 @@ class HitchHarborAgent(BaseAgent):
641
1052
  for value in [event.get("run_id")]
642
1053
  if isinstance(value, str) and re.fullmatch(r"run_[a-f0-9]{32}", value)
643
1054
  ), None)
644
- if observed_run_id:
1055
+ if observed_run_id and prepared_phase is None:
645
1056
  run_id = str(observed_run_id)
646
1057
  result_path = f"/tmp/hitch-state/runs/{run_id}/result.json"
647
1058
  quoted_result_path = shlex.quote(result_path)
@@ -664,7 +1075,7 @@ cat -- {quoted_result_path}
664
1075
  "trial_id": trial_id,
665
1076
  "completed_at": datetime.now(timezone.utc).isoformat(),
666
1077
  }, separators=(",", ":"))
667
- bundle_export = await environment.exec(
1078
+ legacy_export = (
668
1079
  f"""
669
1080
  set -eu
670
1081
  source_dir={shlex.quote(f'/tmp/hitch-state/runs/{run_id}')}
@@ -680,10 +1091,39 @@ rm -rf "$target_dir"
680
1091
  mv "$stage_dir" "$target_dir"
681
1092
  """.strip()
682
1093
  )
1094
+ if prepared_phase is None:
1095
+ bundle_export = await environment.exec(legacy_export)
1096
+ else:
1097
+ export_input = {"sourceDirectory": f"/tmp/hitch-state/runs/{run_id}", "destinationDirectory": bundle_stage,
1098
+ "expected": {"run_id": run_id, "context": context_payload, "parent": parent_payload,
1099
+ "revision_identity": self.revision_identity}}
1100
+ # Completion is outside the bundle: adding even a marker inside a
1101
+ # sealed bundle changes its indexed file set and invalidates it.
1102
+ script = (
1103
+ f"import {{copySealedPhaseRunBundle}} from 'file:///opt/hitch/{PHASE_EXPORT_MODULE}';"
1104
+ "import {open,lstat,rename,writeFile} from 'node:fs/promises';"
1105
+ "const input=JSON.parse(process.argv[1]);"
1106
+ "const target='/logs/agent/hitch-run-bundle';"
1107
+ "const lock=await open('/logs/agent/.hitch-phase-export.lock','wx',0o600);await lock.close();"
1108
+ "try{await lstat(target);throw new Error('phase export target already exists')}catch(e){if(e.code!=='ENOENT')throw e;}"
1109
+ "const index=await copySealedPhaseRunBundle(input);"
1110
+ "await rename(input.destinationDirectory,target);"
1111
+ "await writeFile('/logs/agent/hitch-phase.complete.json',JSON.stringify({schema_version:'1',"
1112
+ "run_id:index.run_id,bundle_digest:index.bundle_digest,scope:'candidate-evidence-only'}),{flag:'wx',mode:0o600});"
1113
+ )
1114
+ bundle_export = await environment.exec(
1115
+ f"{self._node_prefix()} node --input-type=module -e {shlex.quote(script)} {shlex.quote(json.dumps(export_input))}"
1116
+ )
683
1117
  hitch_result, result_error_code, result_error_message = self._parse_hitch_result(result_read, run_id)
1118
+ if prepared_phase is None and hitch_result is not None and getattr(environment, "_hitch_benchmark", None):
1119
+ from hitch_benchmark import export_final_response
1120
+ await export_final_response(environment, hitch_result)
684
1121
  primary_code: str | None = None
685
1122
  primary_message: str | None = None
686
- if execution.return_code != 0:
1123
+ if prepared_phase is not None and observed_run_id and observed_run_id != assigned_run_id:
1124
+ primary_code = "hitch_phase_run_identity_mismatch"
1125
+ primary_message = "Hitch phase emitted a different run ID from its prepared tool binding"
1126
+ elif execution.return_code != 0:
687
1127
  primary_code = "hitch_process_failed"
688
1128
  diagnostic = self._exec_diagnostic(execution)
689
1129
  if hitch_result and isinstance(hitch_result.get("error"), dict):
@@ -711,6 +1151,13 @@ mv "$stage_dir" "$target_dir"
711
1151
  primary_code = "hitch_result_artifact_copy_failed"
712
1152
  primary_message = f"Hitch result artifact copy failed (run_id={run_id}, trial_id={trial_id})"
713
1153
 
1154
+ self._write_trusted_agent_outcome(
1155
+ run_id=run_id,
1156
+ hitch_result=hitch_result,
1157
+ bundle_export=bundle_export,
1158
+ reason_code=primary_code,
1159
+ )
1160
+
714
1161
  context.metadata = {
715
1162
  "candidate_id": self.candidate_id,
716
1163
  "harness_ref": self.harness_ref,
@@ -728,6 +1175,11 @@ mv "$stage_dir" "$target_dir"
728
1175
  "hitch_status": hitch_result.get("status") if hitch_result else None,
729
1176
  "hitch_artifact_id": hitch_result.get("artifact_id") if hitch_result else None,
730
1177
  }
1178
+ if prepared_phase is not None:
1179
+ context.metadata.update(hitch_context_kind="benchmark_phase", hitch_run_group_id=context_payload["run_group_id"],
1180
+ hitch_phase_index=context_payload["phase_index"],
1181
+ hitch_phase_bundle_exported=bundle_export.return_code == 0,
1182
+ hitch_phase_completion="hitch-phase.complete.json")
731
1183
  if self._artifact_manifest is not None:
732
1184
  context.metadata["harness_artifact_transport"] = {
733
1185
  "artifact_id": self._artifact_manifest["artifact_id"],
@@ -945,6 +1397,37 @@ mv "$stage_dir" "$target_dir"
945
1397
  cwd="/",
946
1398
  )
947
1399
 
1400
+ def _write_trusted_agent_outcome(
1401
+ self,
1402
+ *,
1403
+ run_id: str,
1404
+ hitch_result: dict[str, Any] | None,
1405
+ bundle_export: ExecResult,
1406
+ reason_code: str | None,
1407
+ ) -> None:
1408
+ result_status = hitch_result.get("status") if hitch_result else "failed"
1409
+ if result_status not in {"succeeded", "failed", "timed_out", "cancelled"}:
1410
+ result_status = "failed"
1411
+ bundle = "complete" if hitch_result is not None and bundle_export.return_code == 0 else (
1412
+ "missing" if bundle_export.return_code != 0 else "invalid"
1413
+ )
1414
+ outcome = {
1415
+ "schema_version": "1",
1416
+ "run_id": run_id,
1417
+ "status": result_status,
1418
+ "candidate_bundle": bundle,
1419
+ "submission_snapshot": "not-required",
1420
+ "gradeability": "gradeable" if bundle == "complete" else "ungradeable",
1421
+ }
1422
+ if bundle != "complete":
1423
+ outcome["reason_code"] = reason_code or "candidate_evidence_unavailable"
1424
+ self.logs_dir.mkdir(parents=True, exist_ok=True)
1425
+ target = self.logs_dir / HITCH_AGENT_OUTCOME_NAME
1426
+ temporary = target.with_name(f".{target.name}.{uuid.uuid4().hex}.tmp")
1427
+ temporary.write_text(json.dumps(outcome, separators=(",", ":"), sort_keys=True) + "\n", encoding="utf-8")
1428
+ temporary.chmod(0o600)
1429
+ temporary.replace(target)
1430
+
948
1431
  def _trial_identity(self) -> tuple[str, str, int]:
949
1432
  """Read Harbor's stable trial/task identity from the persisted trial state."""
950
1433
  trial_dir = self.logs_dir.parent if self.logs_dir.name == "agent" else self.logs_dir
@@ -1024,43 +1507,158 @@ process.stdout.write('sha256:' + hash.digest('hex'));
1024
1507
  return "sha256:" + hashlib.sha256(b"workspace-unavailable").hexdigest()
1025
1508
 
1026
1509
  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)'"
1510
+ """Select a compatible system Node or an authenticated offline runtime.
1511
+
1512
+ No network, package manager, shell profile, or existing system binary
1513
+ is modified here. The archive travels inside the job-pinned artifact,
1514
+ including on remote workers and reruns.
1515
+ """
1516
+ platform = str((self._artifact_manifest or {}).get("platform", ""))
1517
+ self._node_bin_directory = None
1518
+ check = (
1519
+ f"process.exit(process.version === {json.dumps(self.required_node_version)} && "
1520
+ f"process.platform + '-' + process.arch === {json.dumps(platform)} ? 0 : 1)"
1029
1521
  )
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)
1522
+ staging: str | None = None
1523
+ try:
1524
+ probe = await asyncio.wait_for(environment.exec(f"node -e {shlex.quote(check)}"), timeout=30)
1525
+ if probe.return_code == 0:
1526
+ self._node_bin_directory = None
1527
+ self._record_node_runtime({"source": "system", "node_version": self.required_node_version, "platform": platform})
1528
+ return
1529
+ archive, runtime = self._offline_node_runtime()
1530
+ native = await self._node_setup_exec(environment, """set -eu
1531
+ test "$(uname -s)" = Linux
1532
+ case "$(uname -m)" in
1533
+ x86_64|amd64) echo linux-x64 ;;
1534
+ aarch64|arm64) echo linux-arm64 ;;
1535
+ *) exit 1 ;;
1536
+ esac""", "hitch_node_runtime_incompatible")
1537
+ if (native.stdout or "").strip() != runtime["platform"]:
1538
+ raise self._node_runtime_error("hitch_node_runtime_incompatible", "offline Node architecture does not match the task container")
1539
+ libc = await self._node_setup_exec(environment, "getconf GNU_LIBC_VERSION", "hitch_node_runtime_incompatible")
1540
+ if not re.fullmatch(r"glibc \d+\.\d+", (libc.stdout or "").strip()):
1541
+ raise self._node_runtime_error("hitch_node_runtime_incompatible", "offline Node requires a glibc task image; musl is not supported")
1542
+ await self._node_setup_exec(
1543
+ environment, "command -v sha256sum && command -v tar && command -v gzip", "hitch_node_runtime_prerequisite_missing",
1544
+ )
1545
+ # Unique per setup; never extract over a system Node or an old,
1546
+ # partially installed runtime. The ready directory appears only
1547
+ # after checksum, extraction and executable compatibility checks.
1548
+ target = f"/opt/hitch-node-runtime-{uuid.uuid4().hex}"
1549
+ await self._node_setup_exec(environment, f"mkdir -m 755 {target}", "hitch_node_runtime_install_failed")
1550
+ staging = target
1551
+ await asyncio.wait_for(environment.upload_file(archive, f"{staging}/node-runtime.tar.gz"), timeout=120)
1552
+ checksum = runtime["archive_sha256"].removeprefix("sha256:")
1553
+ await self._node_setup_exec(
1554
+ environment,
1555
+ f"cd {staging} && printf '%s\\n' '{checksum} node-runtime.tar.gz' | sha256sum -c -",
1556
+ "hitch_node_runtime_integrity_mismatch",
1557
+ )
1558
+ await self._node_setup_exec(
1559
+ environment,
1560
+ f"umask 022; mkdir -m 755 {staging}/unpacked && tar --no-same-owner -xzf {staging}/node-runtime.tar.gz -C {staging}/unpacked",
1561
+ "hitch_node_runtime_install_failed",
1562
+ )
1563
+ await self._node_setup_exec(
1564
+ environment, f"{staging}/unpacked/bin/node -e {shlex.quote(check)}", "hitch_node_runtime_incompatible",
1565
+ )
1566
+ await self._node_setup_exec(
1567
+ environment, f"mv {staging}/unpacked {staging}/ready && rm {staging}/node-runtime.tar.gz", "hitch_node_runtime_install_failed",
1568
+ )
1569
+ self._node_bin_directory = f"{staging}/ready/bin"
1570
+ self._record_node_runtime({"source": "offline-artifact", **runtime, "bin_directory": self._node_bin_directory})
1571
+ staging = None
1572
+ except Exception as error:
1573
+ failure = error if isinstance(error, HitchBridgeError) else self._node_runtime_error(
1574
+ "hitch_node_runtime_setup_failed", f"{type(error).__name__}: {error}",
1575
+ )
1576
+ self._record_node_runtime({"source": "failed", **failure.evidence})
1577
+ try:
1578
+ await self._write_bridge_error(environment, failure.evidence)
1579
+ except Exception:
1580
+ pass # Preserve the original Node failure even if log export fails.
1581
+ if failure is error:
1582
+ raise
1583
+ raise failure from error
1584
+ finally:
1585
+ if staging is not None:
1586
+ # Only the random directory created by this setup is removed.
1587
+ try:
1588
+ await asyncio.wait_for(environment.exec(f"rm -rf -- {staging}", user=0), timeout=30)
1589
+ except Exception:
1590
+ pass
1060
1591
 
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;"
1592
+ def _offline_node_runtime(self) -> tuple[Path, dict[str, Any]]:
1593
+ directory = self._artifact_host_directory
1594
+ if directory is None or not (directory / ".hitch-node-runtime").exists():
1595
+ raise self._node_runtime_error(
1596
+ "hitch_node_runtime_missing",
1597
+ "task has no matching Node and the pinned artifact has no offline runtime; prepare a new eval with the updated controller (no online fallback)",
1598
+ )
1599
+ try:
1600
+ # Authenticate the runtime metadata against the job's CONTENT pin,
1601
+ # not a self-reported checksum in a mutable sidecar. This is needed
1602
+ # before Node can run Hitch's normal in-container artifact check.
1603
+ captured = {".hitch-node-runtime/node-runtime.json": b""}
1604
+ if artifact_directory_integrity(directory, captured) != (self.harness_artifact or {}).get("artifact_integrity"):
1605
+ raise RuntimeError("job-pinned harness content digest mismatch before Node bootstrap")
1606
+ bundle = directory / ".hitch-node-runtime"
1607
+ if bundle.is_symlink() or not bundle.is_dir():
1608
+ raise RuntimeError("offline Node bundle must be a regular directory")
1609
+ manifest_path = bundle / "node-runtime.json"
1610
+ self._assert_regular_host_file(manifest_path, "offline Node manifest", 16_384)
1611
+ # Parse exactly the bytes hashed above, not a second sidecar read
1612
+ # which could race a cache mutation after content authentication.
1613
+ runtime = json.loads(captured[".hitch-node-runtime/node-runtime.json"].decode("utf-8"))
1614
+ fields = {"schema_version", "recipe_version", "runtime_id", "node_version", "platform", "libc", "builder_image_id", "archive_sha256", "archive_bytes"}
1615
+ if not isinstance(runtime, dict) or set(runtime) != fields:
1616
+ raise RuntimeError("offline Node manifest fields are invalid")
1617
+ payload = {key: value for key, value in runtime.items() if key != "runtime_id"}
1618
+ identity = "sha256:" + hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
1619
+ if runtime["schema_version"] != "1" or runtime["recipe_version"] != "1" or runtime["runtime_id"] != identity:
1620
+ raise RuntimeError("offline Node runtime identity is invalid")
1621
+ if runtime["node_version"] != self.required_node_version or runtime["platform"] != (self._artifact_manifest or {}).get("platform") or runtime["libc"] != "glibc":
1622
+ raise RuntimeError("offline Node manifest does not match the job runtime contract")
1623
+ for field in ("archive_sha256", "builder_image_id"):
1624
+ if not isinstance(runtime[field], str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", runtime[field]):
1625
+ raise RuntimeError(f"offline Node {field} is invalid")
1626
+ archive = bundle / "node-runtime.tar.gz"
1627
+ info = self._assert_regular_host_file(archive, "offline Node archive", 128 * 1024 * 1024)
1628
+ if type(runtime["archive_bytes"]) is not int or info.st_size != runtime["archive_bytes"] or info.st_size <= 0:
1629
+ raise RuntimeError("offline Node archive size mismatch")
1630
+ archive_digest = hashlib.sha256()
1631
+ with archive.open("rb") as handle:
1632
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
1633
+ archive_digest.update(chunk)
1634
+ if "sha256:" + archive_digest.hexdigest() != runtime["archive_sha256"]:
1635
+ raise RuntimeError("offline Node archive checksum mismatch")
1636
+ return archive, runtime
1637
+ except Exception as error:
1638
+ raise self._node_runtime_error("hitch_node_runtime_integrity_mismatch", str(error)) from error
1639
+
1640
+ async def _node_setup_exec(self, environment: BaseEnvironment, command: str, code: str) -> ExecResult:
1641
+ result = await asyncio.wait_for(environment.exec(command, user=0), timeout=60)
1642
+ if result.return_code != 0:
1643
+ raise self._node_runtime_error(code, f"exit={result.return_code}: {self._exec_diagnostic(result)}")
1644
+ return result
1645
+
1646
+ def _node_runtime_error(self, code: str, message: str) -> HitchBridgeError:
1647
+ return HitchBridgeError(code, self._bounded_tail(message, 2048), {
1648
+ "schema_version": "1", "code": code, "message": self._bounded_tail(message, 2048),
1649
+ "eval_id": self.eval_id, "node_version": self.required_node_version,
1650
+ "platform": (self._artifact_manifest or {}).get("platform"),
1651
+ "artifact_id": (self.harness_artifact or {}).get("artifact_id"),
1652
+ })
1653
+
1654
+ def _record_node_runtime(self, evidence: dict[str, Any]) -> None:
1655
+ self.logs_dir.mkdir(parents=True, exist_ok=True)
1656
+ (self.logs_dir / "hitch-node-runtime.json").write_text(
1657
+ json.dumps({"schema_version": "1", **evidence}, indent=2, sort_keys=True) + "\n", encoding="utf-8",
1658
+ )
1659
+
1660
+ def _node_prefix(self) -> str:
1661
+ return f"export PATH={shlex.quote(self._node_bin_directory)}:\"$PATH\";" if self._node_bin_directory else ""
1064
1662
 
1065
1663
  @staticmethod
1066
1664
  def _events(output: str) -> list[dict[str, Any]]: