agent-hitch 0.2.0 → 0.2.1

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 (263) hide show
  1. package/README.md +16 -10
  2. package/README.zh-CN.md +13 -8
  3. package/dist/bin/hitch.js +1 -1
  4. package/dist/bin/hitch.js.map +1 -1
  5. package/dist/scripts/check-architecture.js +159 -0
  6. package/dist/scripts/check-architecture.js.map +1 -0
  7. package/dist/scripts/check-release.js +1 -1
  8. package/dist/scripts/check-release.js.map +1 -1
  9. package/dist/src/adapters/catalog.js +55 -0
  10. package/dist/src/adapters/catalog.js.map +1 -0
  11. package/dist/src/adapters/contract.js +2 -0
  12. package/dist/src/adapters/contract.js.map +1 -0
  13. package/dist/src/adapters/discovery.js +22 -0
  14. package/dist/src/adapters/discovery.js.map +1 -0
  15. package/dist/src/adapters/index.js +3 -0
  16. package/dist/src/adapters/index.js.map +1 -0
  17. package/dist/src/adapters/providers/claude.js +69 -0
  18. package/dist/src/adapters/providers/claude.js.map +1 -0
  19. package/dist/src/adapters/providers/codex.js +74 -0
  20. package/dist/src/adapters/providers/codex.js.map +1 -0
  21. package/dist/src/adapters/providers/deepseek.js +51 -0
  22. package/dist/src/adapters/providers/deepseek.js.map +1 -0
  23. package/dist/src/adapters/providers/opencode.js +73 -0
  24. package/dist/src/adapters/providers/opencode.js.map +1 -0
  25. package/dist/src/adapters/providers/pi.js +82 -0
  26. package/dist/src/adapters/providers/pi.js.map +1 -0
  27. package/dist/src/adapters/providers/shared.js +82 -0
  28. package/dist/src/adapters/providers/shared.js.map +1 -0
  29. package/dist/src/artifacts/index.js +3 -0
  30. package/dist/src/artifacts/index.js.map +1 -0
  31. package/dist/src/artifacts/integrity.js +188 -0
  32. package/dist/src/artifacts/integrity.js.map +1 -0
  33. package/dist/src/artifacts/preparer.js +379 -0
  34. package/dist/src/artifacts/preparer.js.map +1 -0
  35. package/dist/src/artifacts/store.js +87 -0
  36. package/dist/src/artifacts/store.js.map +1 -0
  37. package/dist/src/artifacts/types.js +2 -0
  38. package/dist/src/artifacts/types.js.map +1 -0
  39. package/dist/src/backends/contract.js +2 -0
  40. package/dist/src/backends/contract.js.map +1 -0
  41. package/dist/src/{harbor-backend.js → backends/harbor/backend.js} +39 -14
  42. package/dist/src/backends/harbor/backend.js.map +1 -0
  43. package/dist/src/backends/harbor/index.js +4 -0
  44. package/dist/src/backends/harbor/index.js.map +1 -0
  45. package/dist/src/backends/harbor/local-git-transport.js +447 -0
  46. package/dist/src/backends/harbor/local-git-transport.js.map +1 -0
  47. package/dist/src/{eval-tools.js → backends/harbor/tools.js} +2 -6
  48. package/dist/src/backends/harbor/tools.js.map +1 -0
  49. package/dist/src/backends/index.js +4 -0
  50. package/dist/src/backends/index.js.map +1 -0
  51. package/dist/src/cli/arguments.js +123 -0
  52. package/dist/src/cli/arguments.js.map +1 -0
  53. package/dist/src/cli/commands/compare.js +55 -0
  54. package/dist/src/cli/commands/compare.js.map +1 -0
  55. package/dist/src/cli/commands/daemon.js +106 -0
  56. package/dist/src/cli/commands/daemon.js.map +1 -0
  57. package/dist/src/cli/commands/eval.js +126 -0
  58. package/dist/src/cli/commands/eval.js.map +1 -0
  59. package/dist/src/cli/commands/feedback.js +84 -0
  60. package/dist/src/cli/commands/feedback.js.map +1 -0
  61. package/dist/src/cli/commands/inspect.js +20 -0
  62. package/dist/src/cli/commands/inspect.js.map +1 -0
  63. package/dist/src/cli/commands/list.js +17 -0
  64. package/dist/src/cli/commands/list.js.map +1 -0
  65. package/dist/src/cli/commands/prepare.js +21 -0
  66. package/dist/src/cli/commands/prepare.js.map +1 -0
  67. package/dist/src/cli/commands/resolve.js +17 -0
  68. package/dist/src/cli/commands/resolve.js.map +1 -0
  69. package/dist/src/cli/commands/run.js +83 -0
  70. package/dist/src/cli/commands/run.js.map +1 -0
  71. package/dist/src/cli/commands/runs.js +45 -0
  72. package/dist/src/cli/commands/runs.js.map +1 -0
  73. package/dist/src/cli/commands/trajectory.js +31 -0
  74. package/dist/src/cli/commands/trajectory.js.map +1 -0
  75. package/dist/src/cli/commands/workspace.js +44 -0
  76. package/dist/src/cli/commands/workspace.js.map +1 -0
  77. package/dist/src/cli/index.js +2 -0
  78. package/dist/src/cli/index.js.map +1 -0
  79. package/dist/src/cli/main.js +48 -0
  80. package/dist/src/cli/main.js.map +1 -0
  81. package/dist/src/cli/output.js +96 -0
  82. package/dist/src/cli/output.js.map +1 -0
  83. package/dist/src/controller-runtime/index.js +3 -0
  84. package/dist/src/controller-runtime/index.js.map +1 -0
  85. package/dist/src/controller-runtime/store.js +2 -7
  86. package/dist/src/controller-runtime/store.js.map +1 -1
  87. package/dist/src/daemon/auth.js +108 -0
  88. package/dist/src/daemon/auth.js.map +1 -0
  89. package/dist/src/daemon/client.js +76 -0
  90. package/dist/src/daemon/client.js.map +1 -0
  91. package/dist/src/daemon/index.js +5 -0
  92. package/dist/src/daemon/index.js.map +1 -0
  93. package/dist/src/daemon/launcher.js +24 -0
  94. package/dist/src/daemon/launcher.js.map +1 -0
  95. package/dist/src/{scheduler.js → daemon/scheduler.js} +6 -7
  96. package/dist/src/daemon/scheduler.js.map +1 -0
  97. package/dist/src/{daemon.js → daemon/server.js} +10 -155
  98. package/dist/src/daemon/server.js.map +1 -0
  99. package/dist/src/domain/artifacts.js +2 -0
  100. package/dist/src/domain/artifacts.js.map +1 -0
  101. package/dist/src/domain/controller-runtime.js +2 -0
  102. package/dist/src/domain/controller-runtime.js.map +1 -0
  103. package/dist/src/domain/eval-records.js +2 -0
  104. package/dist/src/domain/eval-records.js.map +1 -0
  105. package/dist/src/domain/evals.js +2 -0
  106. package/dist/src/domain/evals.js.map +1 -0
  107. package/dist/src/domain/feedback.js +2 -0
  108. package/dist/src/domain/feedback.js.map +1 -0
  109. package/dist/src/domain/{types.js → ids.js} +1 -1
  110. package/dist/src/domain/ids.js.map +1 -0
  111. package/dist/src/domain/index.js +11 -0
  112. package/dist/src/domain/index.js.map +1 -0
  113. package/dist/src/domain/revisions.js +2 -0
  114. package/dist/src/domain/revisions.js.map +1 -0
  115. package/dist/src/domain/runs.js +3 -0
  116. package/dist/src/domain/runs.js.map +1 -0
  117. package/dist/src/domain/trajectories.js +2 -0
  118. package/dist/src/domain/trajectories.js.map +1 -0
  119. package/dist/src/domain/{validate.js → validation.js} +194 -5
  120. package/dist/src/domain/validation.js.map +1 -0
  121. package/dist/src/domain/workspaces.js +2 -0
  122. package/dist/src/domain/workspaces.js.map +1 -0
  123. package/dist/src/evals/events.js +67 -0
  124. package/dist/src/evals/events.js.map +1 -0
  125. package/dist/src/evals/index.js +5 -0
  126. package/dist/src/evals/index.js.map +1 -0
  127. package/dist/src/evals/records.js +55 -0
  128. package/dist/src/evals/records.js.map +1 -0
  129. package/dist/src/evals/request.js +109 -0
  130. package/dist/src/evals/request.js.map +1 -0
  131. package/dist/src/evals/service.js +227 -0
  132. package/dist/src/evals/service.js.map +1 -0
  133. package/dist/src/evals/trial-import.js +394 -0
  134. package/dist/src/evals/trial-import.js.map +1 -0
  135. package/dist/src/feedback/index.js +2 -0
  136. package/dist/src/feedback/index.js.map +1 -0
  137. package/dist/src/feedback/service.js +3 -4
  138. package/dist/src/feedback/service.js.map +1 -1
  139. package/dist/src/{config.js → foundation/config.js} +1 -0
  140. package/dist/src/foundation/config.js.map +1 -0
  141. package/dist/src/foundation/errors.js.map +1 -0
  142. package/dist/src/foundation/executable.js +135 -0
  143. package/dist/src/foundation/executable.js.map +1 -0
  144. package/dist/src/foundation/fs.js.map +1 -0
  145. package/dist/src/foundation/hash.js +24 -0
  146. package/dist/src/foundation/hash.js.map +1 -0
  147. package/dist/src/foundation/index.js +11 -0
  148. package/dist/src/foundation/index.js.map +1 -0
  149. package/dist/src/foundation/line-stream.js.map +1 -0
  150. package/dist/src/foundation/locks.js +114 -0
  151. package/dist/src/foundation/locks.js.map +1 -0
  152. package/dist/src/{package-root.js → foundation/package-root.js} +3 -2
  153. package/dist/src/foundation/package-root.js.map +1 -0
  154. package/dist/src/foundation/process.js.map +1 -0
  155. package/dist/src/revisions/index.js +4 -0
  156. package/dist/src/revisions/index.js.map +1 -0
  157. package/dist/src/{harness-reference.js → revisions/reference.js} +16 -2
  158. package/dist/src/revisions/reference.js.map +1 -0
  159. package/dist/src/revisions/resolver.js +25 -0
  160. package/dist/src/revisions/resolver.js.map +1 -0
  161. package/dist/src/revisions/sources/git.js +180 -0
  162. package/dist/src/revisions/sources/git.js.map +1 -0
  163. package/dist/src/revisions/sources/installed.js +45 -0
  164. package/dist/src/revisions/sources/installed.js.map +1 -0
  165. package/dist/src/revisions/sources/npm.js +97 -0
  166. package/dist/src/revisions/sources/npm.js.map +1 -0
  167. package/dist/src/runs/compare.js +132 -0
  168. package/dist/src/runs/compare.js.map +1 -0
  169. package/dist/src/{events.js → runs/events.js} +1 -2
  170. package/dist/src/runs/events.js.map +1 -0
  171. package/dist/src/{engine.js → runs/executor.js} +166 -151
  172. package/dist/src/runs/executor.js.map +1 -0
  173. package/dist/src/runs/finalizer.js +22 -0
  174. package/dist/src/runs/finalizer.js.map +1 -0
  175. package/dist/src/runs/identity.js +35 -0
  176. package/dist/src/runs/identity.js.map +1 -0
  177. package/dist/src/runs/index.js +11 -0
  178. package/dist/src/runs/index.js.map +1 -0
  179. package/dist/src/runs/manifest.js +70 -0
  180. package/dist/src/runs/manifest.js.map +1 -0
  181. package/dist/src/runs/outcome.js +49 -0
  182. package/dist/src/runs/outcome.js.map +1 -0
  183. package/dist/src/runs/query.js +104 -0
  184. package/dist/src/runs/query.js.map +1 -0
  185. package/dist/src/runs/queued.js +54 -0
  186. package/dist/src/runs/queued.js.map +1 -0
  187. package/dist/src/runs/records.js +288 -0
  188. package/dist/src/runs/records.js.map +1 -0
  189. package/dist/src/runs/request.js +169 -0
  190. package/dist/src/runs/request.js.map +1 -0
  191. package/dist/src/trajectories/format.js +1 -1
  192. package/dist/src/trajectories/format.js.map +1 -1
  193. package/dist/src/trajectories/index.js +7 -0
  194. package/dist/src/trajectories/index.js.map +1 -0
  195. package/dist/src/trajectories/projector.js +1 -1
  196. package/dist/src/trajectories/projector.js.map +1 -1
  197. package/dist/src/trajectories/provider-capture.js +133 -0
  198. package/dist/src/trajectories/provider-capture.js.map +1 -0
  199. package/dist/src/trajectories/providers/deepseek.js +179 -0
  200. package/dist/src/trajectories/providers/deepseek.js.map +1 -0
  201. package/dist/src/trajectories/store.js +148 -8
  202. package/dist/src/trajectories/store.js.map +1 -1
  203. package/dist/src/workspaces/copy.js +142 -0
  204. package/dist/src/workspaces/copy.js.map +1 -0
  205. package/dist/src/workspaces/digest.js +47 -0
  206. package/dist/src/workspaces/digest.js.map +1 -0
  207. package/dist/src/workspaces/git.js +164 -0
  208. package/dist/src/workspaces/git.js.map +1 -0
  209. package/dist/src/workspaces/index.js +6 -0
  210. package/dist/src/workspaces/index.js.map +1 -0
  211. package/dist/src/workspaces/lifecycle.js +313 -0
  212. package/dist/src/workspaces/lifecycle.js.map +1 -0
  213. package/dist/src/workspaces/planner.js +80 -0
  214. package/dist/src/workspaces/planner.js.map +1 -0
  215. package/dist/src/workspaces/store.js +25 -0
  216. package/dist/src/workspaces/store.js.map +1 -0
  217. package/dist/src/workspaces/types.js +4 -0
  218. package/dist/src/workspaces/types.js.map +1 -0
  219. package/dist/src/workspaces/utils.js +56 -0
  220. package/dist/src/workspaces/utils.js.map +1 -0
  221. package/docs/schemas/eval-request.schema.json +2 -0
  222. package/docs/schemas/eval-result.schema.json +36 -1
  223. package/docs/schemas/local-git-transport-manifest.schema.json +34 -0
  224. package/docs/schemas/result.schema.json +9 -0
  225. package/docs/schemas/run-context.schema.json +39 -0
  226. package/docs/schemas/run-manifest.schema.json +81 -0
  227. package/docs/schemas/run-request.schema.json +16 -1
  228. package/docs/schemas/trajectory-ref.schema.json +66 -23
  229. package/integrations/harbor/hitch_harbor_agent.py +375 -1
  230. package/package.json +21 -3
  231. package/dist/src/adapters.js +0 -461
  232. package/dist/src/adapters.js.map +0 -1
  233. package/dist/src/artifacts.js +0 -1081
  234. package/dist/src/artifacts.js.map +0 -1
  235. package/dist/src/cli.js +0 -699
  236. package/dist/src/cli.js.map +0 -1
  237. package/dist/src/config.js.map +0 -1
  238. package/dist/src/daemon.js.map +0 -1
  239. package/dist/src/domain/types.js.map +0 -1
  240. package/dist/src/domain/validate.js.map +0 -1
  241. package/dist/src/engine.js.map +0 -1
  242. package/dist/src/errors.js.map +0 -1
  243. package/dist/src/eval-tools.js.map +0 -1
  244. package/dist/src/evals.js +0 -313
  245. package/dist/src/evals.js.map +0 -1
  246. package/dist/src/events.js.map +0 -1
  247. package/dist/src/fs.js.map +0 -1
  248. package/dist/src/harbor-backend.js.map +0 -1
  249. package/dist/src/harness-reference.js.map +0 -1
  250. package/dist/src/line-stream.js.map +0 -1
  251. package/dist/src/locks.js +0 -38
  252. package/dist/src/locks.js.map +0 -1
  253. package/dist/src/package-root.js.map +0 -1
  254. package/dist/src/process.js.map +0 -1
  255. package/dist/src/registry.js +0 -81
  256. package/dist/src/registry.js.map +0 -1
  257. package/dist/src/scheduler.js.map +0 -1
  258. package/dist/src/workspaces.js +0 -858
  259. package/dist/src/workspaces.js.map +0 -1
  260. /package/dist/src/{errors.js → foundation/errors.js} +0 -0
  261. /package/dist/src/{fs.js → foundation/fs.js} +0 -0
  262. /package/dist/src/{line-stream.js → foundation/line-stream.js} +0 -0
  263. /package/dist/src/{process.js → foundation/process.js} +0 -0
@@ -6,7 +6,10 @@ import hashlib
6
6
  import json
7
7
  import re
8
8
  import shlex
9
+ import stat as stat_module
9
10
  import tempfile
11
+ import uuid
12
+ from datetime import datetime
10
13
  from pathlib import Path
11
14
  from typing import Any
12
15
 
@@ -15,6 +18,9 @@ from harbor.environments.base import BaseEnvironment, ExecResult
15
18
  from harbor.models.agent.context import AgentContext
16
19
 
17
20
  CONTROLLER_RUNTIME_MANIFEST_VERSION = "2"
21
+ LOCAL_GIT_TRANSPORT_MANIFEST_VERSION = "1"
22
+ LOCAL_GIT_TRANSPORT_MAX_BYTES = 512 * 1024 * 1024
23
+ LOCAL_GIT_REMOTE_ROOT = "/opt/hitch-local-source"
18
24
 
19
25
 
20
26
  class HitchHarborAgent(BaseAgent):
@@ -28,9 +34,14 @@ class HitchHarborAgent(BaseAgent):
28
34
  hitch_runtime_dir: str,
29
35
  candidate_id: str = "candidate-1",
30
36
  controller_runtime_id: str | None = None,
37
+ local_source_transport: dict[str, Any] | None = None,
31
38
  hitch_timeout_ms: int = 900_000,
32
39
  agent_args: list[str] | None = None,
33
40
  workdir: str = "/app",
41
+ eval_id: str | None = None,
42
+ benchmark_id: str | None = None,
43
+ benchmark_revision: str | None = None,
44
+ verifier_identity: str | None = None,
34
45
  **kwargs: Any,
35
46
  ) -> None:
36
47
  super().__init__(logs_dir=logs_dir, **kwargs)
@@ -38,12 +49,18 @@ class HitchHarborAgent(BaseAgent):
38
49
  self.revision_identity = revision_identity
39
50
  self.hitch_runtime_dir = Path(hitch_runtime_dir)
40
51
  self.controller_runtime_id = controller_runtime_id
52
+ self.local_source_transport = dict(local_source_transport) if local_source_transport else None
41
53
  self.candidate_id = candidate_id
42
54
  self.hitch_timeout_ms = int(hitch_timeout_ms)
43
55
  self.agent_args = list(agent_args or [])
44
56
  self.workdir = workdir
57
+ self.eval_id = eval_id
58
+ self.benchmark_id = benchmark_id
59
+ self.benchmark_revision = benchmark_revision
60
+ self.verifier_identity = verifier_identity
45
61
  self._hitch_version: str | None = None
46
62
  self._entrypoint: str | None = None
63
+ self._local_manifest: dict[str, Any] | None = None
47
64
 
48
65
  @staticmethod
49
66
  def name() -> str:
@@ -76,6 +93,11 @@ class HitchHarborAgent(BaseAgent):
76
93
  # and the actual container upload, spec §4.6).
77
94
  self._verify_manifest_identity(manifest)
78
95
  self._verify_payload(manifest)
96
+ if self.local_source_transport is not None:
97
+ try:
98
+ self._local_manifest = self._verify_local_source_host()
99
+ except Exception as error:
100
+ raise RuntimeError(f"hitch-local-source-materialize: {error}") from error
79
101
  payload_dir = self.hitch_runtime_dir / "payload"
80
102
  if not payload_dir.is_dir():
81
103
  raise RuntimeError(f"Hitch runtime bundle has no payload directory: {self.hitch_runtime_dir}")
@@ -84,6 +106,11 @@ class HitchHarborAgent(BaseAgent):
84
106
  # bookkeeping and is not identity (spec §4.2).
85
107
  await environment.upload_dir(payload_dir, "/opt/hitch")
86
108
  await self._ensure_node(environment)
109
+ if self._local_manifest is not None:
110
+ try:
111
+ await self._upload_and_materialize_local_source(environment, self._local_manifest)
112
+ except Exception as error:
113
+ raise RuntimeError(f"hitch-local-source-materialize: {error}") from error
87
114
  entry = self._remote_entry(entrypoint)
88
115
  version = await self._exec(environment, f"{self._node_prefix()} node {entry} --version")
89
116
  self._hitch_version = (version.stdout or "").strip() or None
@@ -91,13 +118,202 @@ class HitchHarborAgent(BaseAgent):
91
118
  [
92
119
  self._node_prefix(),
93
120
  "HITCH_ROOT=/tmp/hitch-state",
121
+ *(["HITCH_HARBOR_INTERNAL=1"] if self._local_manifest is not None else []),
94
122
  f"node {entry} prepare",
95
123
  shlex.quote(self.harness_ref),
124
+ *self._local_source_cli_args(),
96
125
  "--json",
97
126
  ]
98
127
  )
99
128
  await self._exec(environment, prepare)
100
129
 
130
+ def _verify_local_source_host(self) -> dict[str, Any]:
131
+ """Validate the independent local-source handoff immediately before upload."""
132
+ transport = self.local_source_transport or {}
133
+ required = {
134
+ "kind", "manifest_path", "payload_path", "locked_resolution_path", "harness_id",
135
+ "resolution_identity", "commit", "tree", "payload_sha256", "payload_bytes",
136
+ "object_count", "file_count",
137
+ }
138
+ if set(transport) != required:
139
+ raise RuntimeError("local source transport metadata fields are invalid")
140
+ manifest_path = Path(str(transport["manifest_path"]))
141
+ payload_path = Path(str(transport["payload_path"]))
142
+ resolution_path = Path(str(transport["locked_resolution_path"]))
143
+ if not all(candidate.is_absolute() for candidate in (manifest_path, payload_path, resolution_path)):
144
+ raise RuntimeError("local source transport host paths must be absolute")
145
+ self._assert_regular_host_file(manifest_path, "local source manifest", 64 * 1024)
146
+ payload_stat = self._assert_regular_host_file(payload_path, "local source payload", LOCAL_GIT_TRANSPORT_MAX_BYTES)
147
+ self._assert_regular_host_file(resolution_path, "local source locked resolution", 1024 * 1024)
148
+ try:
149
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
150
+ resolution = json.loads(resolution_path.read_text(encoding="utf-8"))
151
+ except (OSError, UnicodeError, json.JSONDecodeError) as error:
152
+ raise RuntimeError(f"local source metadata is unreadable: {error}") from error
153
+ self._validate_local_manifest(manifest)
154
+ pinned = {
155
+ "kind": manifest["kind"],
156
+ "harness_id": manifest["harness_id"],
157
+ "resolution_identity": manifest["resolution_identity"],
158
+ "commit": manifest["commit"],
159
+ "tree": manifest["tree"],
160
+ "payload_sha256": manifest["payload_sha256"],
161
+ "payload_bytes": manifest["payload_bytes"],
162
+ "object_count": manifest["object_count"],
163
+ "file_count": manifest["file_count"],
164
+ }
165
+ if any(transport.get(key) != value for key, value in pinned.items()):
166
+ raise RuntimeError("local source transport metadata does not match its manifest")
167
+ if manifest["resolution_identity"] != self.revision_identity:
168
+ raise RuntimeError("local source resolution identity does not match the job-pinned identity")
169
+ if self.harness_ref != f'{manifest["harness_id"]}@commit:{manifest["commit"]}':
170
+ raise RuntimeError("local source commit does not match the job-pinned harness ref")
171
+ if payload_stat.st_size != manifest["payload_bytes"]:
172
+ raise RuntimeError("local source payload size does not match its manifest")
173
+ actual_digest = self._sha256_file(payload_path)
174
+ if actual_digest != manifest["payload_sha256"]:
175
+ raise RuntimeError("local source payload digest does not match its manifest")
176
+ if not isinstance(resolution, dict) or (
177
+ resolution.get("harness_id") != manifest["harness_id"]
178
+ or resolution.get("identity") != manifest["resolution_identity"]
179
+ or resolution.get("revision", {}).get("commit") != manifest["commit"]
180
+ or resolution.get("source", {}).get("type") != "git"
181
+ or resolution.get("source", {}).get("registered") is not False
182
+ ):
183
+ raise RuntimeError("local source locked resolution does not match its manifest")
184
+ return manifest
185
+
186
+ @staticmethod
187
+ def _assert_regular_host_file(path: Path, label: str, maximum: int) -> Any:
188
+ try:
189
+ info = path.lstat()
190
+ except OSError as error:
191
+ raise RuntimeError(f"{label} is missing or unreadable") from error
192
+ if not stat_module.S_ISREG(info.st_mode):
193
+ raise RuntimeError(f"{label} must be a regular file")
194
+ if info.st_size > maximum:
195
+ raise RuntimeError(f"{label} exceeds the size limit ({maximum} bytes)")
196
+ return info
197
+
198
+ @staticmethod
199
+ def _sha256_file(path: Path) -> str:
200
+ digest = hashlib.sha256()
201
+ with path.open("rb") as handle:
202
+ while chunk := handle.read(1024 * 1024):
203
+ digest.update(chunk)
204
+ return "sha256:" + digest.hexdigest()
205
+
206
+ @staticmethod
207
+ def _validate_local_manifest(manifest: Any) -> None:
208
+ allowed = {
209
+ "schema_version", "kind", "harness_id", "resolution_identity", "commit", "tree",
210
+ "payload_sha256", "payload_bytes", "object_count", "file_count", "created_at",
211
+ }
212
+ if not isinstance(manifest, dict) or set(manifest) != allowed:
213
+ raise RuntimeError("local source manifest fields are invalid")
214
+ if manifest.get("schema_version") != LOCAL_GIT_TRANSPORT_MANIFEST_VERSION:
215
+ raise RuntimeError("unsupported local source transport manifest schema")
216
+ if manifest.get("kind") != "local-git-commit":
217
+ raise RuntimeError("unsupported local source transport kind")
218
+ if not isinstance(manifest.get("harness_id"), str) or not re.fullmatch(r"[a-z][a-z0-9-]*", manifest["harness_id"]):
219
+ raise RuntimeError("local source manifest harness id is invalid")
220
+ if not isinstance(manifest.get("resolution_identity"), str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", manifest["resolution_identity"]):
221
+ raise RuntimeError("local source manifest resolution identity is invalid")
222
+ commit = manifest.get("commit")
223
+ if not isinstance(commit, str) or not re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", commit):
224
+ raise RuntimeError("local source manifest commit is not a full lowercase OID")
225
+ tree = manifest.get("tree")
226
+ if not isinstance(tree, str) or not re.fullmatch(rf"[0-9a-f]{{{len(commit)}}}", tree):
227
+ raise RuntimeError("local source manifest tree is invalid")
228
+ if not isinstance(manifest.get("payload_sha256"), str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", manifest["payload_sha256"]):
229
+ raise RuntimeError("local source manifest payload digest is invalid")
230
+ for key, maximum in (
231
+ ("payload_bytes", LOCAL_GIT_TRANSPORT_MAX_BYTES),
232
+ ("object_count", 100_000),
233
+ ("file_count", 50_000),
234
+ ):
235
+ value = manifest.get(key)
236
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0 or value > maximum:
237
+ raise RuntimeError(f"local source manifest {key} is invalid")
238
+ if not isinstance(manifest.get("created_at"), str) or not manifest["created_at"]:
239
+ raise RuntimeError("local source manifest created_at is invalid")
240
+ try:
241
+ datetime.fromisoformat(manifest["created_at"].replace("Z", "+00:00"))
242
+ except ValueError as error:
243
+ raise RuntimeError("local source manifest created_at is invalid") from error
244
+
245
+ async def _upload_and_materialize_local_source(
246
+ self,
247
+ environment: BaseEnvironment,
248
+ manifest: dict[str, Any],
249
+ ) -> None:
250
+ transport = self.local_source_transport or {}
251
+ await self._exec(environment, f"mkdir -p {LOCAL_GIT_REMOTE_ROOT} && chmod 700 {LOCAL_GIT_REMOTE_ROOT}")
252
+ await environment.upload_file(Path(str(transport["manifest_path"])), f"{LOCAL_GIT_REMOTE_ROOT}/manifest.json")
253
+ await environment.upload_file(Path(str(transport["payload_path"])), f"{LOCAL_GIT_REMOTE_ROOT}/payload.pack")
254
+ await environment.upload_file(Path(str(transport["locked_resolution_path"])), f"{LOCAL_GIT_REMOTE_ROOT}/resolution.json")
255
+ await self._exec(environment, f"chmod 600 {LOCAL_GIT_REMOTE_ROOT}/manifest.json {LOCAL_GIT_REMOTE_ROOT}/payload.pack {LOCAL_GIT_REMOTE_ROOT}/resolution.json")
256
+ # Verify both the uploaded manifest fields and payload bytes inside the
257
+ # trial before importing any Git object. All interpolated values passed
258
+ # to the shell have already been restricted to digest/OID grammars.
259
+ verifier = """
260
+ const fs = require('node:fs');
261
+ const crypto = require('node:crypto');
262
+ const expected = JSON.parse(process.argv[1]);
263
+ const manifest = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
264
+ if (JSON.stringify(Object.keys(manifest).sort()) !== JSON.stringify(Object.keys(expected).sort())) throw new Error('manifest fields mismatch');
265
+ for (const key of Object.keys(expected)) if (manifest[key] !== expected[key]) throw new Error(`manifest mismatch: ${key}`);
266
+ if (fs.statSync(process.argv[3]).size !== expected.payload_bytes) throw new Error('payload size mismatch');
267
+ (async () => {
268
+ const hash = crypto.createHash('sha256');
269
+ for await (const chunk of fs.createReadStream(process.argv[3])) hash.update(chunk);
270
+ const digest = 'sha256:' + hash.digest('hex');
271
+ if (digest !== expected.payload_sha256) throw new Error('payload digest mismatch');
272
+ })().catch((error) => { console.error(error.message); process.exit(1); });
273
+ """.strip()
274
+ expected = {
275
+ "schema_version": manifest["schema_version"],
276
+ "kind": manifest["kind"],
277
+ "harness_id": manifest["harness_id"],
278
+ "resolution_identity": manifest["resolution_identity"],
279
+ "commit": manifest["commit"],
280
+ "tree": manifest["tree"],
281
+ "payload_sha256": manifest["payload_sha256"],
282
+ "payload_bytes": manifest["payload_bytes"],
283
+ "object_count": manifest["object_count"],
284
+ "file_count": manifest["file_count"],
285
+ "created_at": manifest["created_at"],
286
+ }
287
+ verify_command = " ".join([
288
+ self._node_prefix(),
289
+ "node", "-e", shlex.quote(verifier),
290
+ shlex.quote(json.dumps(expected, separators=(",", ":"))),
291
+ f"{LOCAL_GIT_REMOTE_ROOT}/manifest.json",
292
+ f"{LOCAL_GIT_REMOTE_ROOT}/payload.pack",
293
+ ])
294
+ await self._exec(environment, verify_command)
295
+ commit = manifest["commit"]
296
+ tree = manifest["tree"]
297
+ materialize = f"""
298
+ set -eu
299
+ git init --bare {LOCAL_GIT_REMOTE_ROOT}/repo.git >/dev/null
300
+ git -C {LOCAL_GIT_REMOTE_ROOT}/repo.git index-pack --stdin < {LOCAL_GIT_REMOTE_ROOT}/payload.pack >/dev/null
301
+ test "$(git -C {LOCAL_GIT_REMOTE_ROOT}/repo.git rev-parse {commit}^{{commit}})" = "{commit}"
302
+ test "$(git -C {LOCAL_GIT_REMOTE_ROOT}/repo.git rev-parse {commit}^{{tree}})" = "{tree}"
303
+ printf '%s\n' {commit} > {LOCAL_GIT_REMOTE_ROOT}/repo.git/shallow
304
+ git -C {LOCAL_GIT_REMOTE_ROOT}/repo.git update-ref refs/heads/hitch-local {commit}
305
+ """
306
+ await self._exec(environment, materialize)
307
+
308
+ def _local_source_cli_args(self) -> list[str]:
309
+ if self._local_manifest is None:
310
+ return []
311
+ return [
312
+ "--internal-locked-resolution", f"{LOCAL_GIT_REMOTE_ROOT}/resolution.json",
313
+ "--internal-local-git-manifest", f"{LOCAL_GIT_REMOTE_ROOT}/manifest.json",
314
+ "--internal-local-git-source", f"{LOCAL_GIT_REMOTE_ROOT}/repo.git",
315
+ ]
316
+
101
317
  @staticmethod
102
318
  def _remote_entry(entrypoint: str) -> str:
103
319
  """Shell-quote the full remote path so the entrypoint is always a
@@ -196,7 +412,41 @@ class HitchHarborAgent(BaseAgent):
196
412
  context: AgentContext,
197
413
  ) -> None:
198
414
  self.logs_dir.mkdir(parents=True, exist_ok=True)
415
+ run_id = "run_" + uuid.uuid4().hex
416
+ trial_id, task_id, attempt = self._trial_identity()
417
+ context_payload: dict[str, Any] = {"kind": "ad_hoc"}
418
+ parent_payload: dict[str, Any] | None = None
419
+ if all((self.eval_id, self.benchmark_id, self.benchmark_revision, self.verifier_identity)):
420
+ workspace_digest = await self._workspace_digest(environment)
421
+ task_digest_input = json.dumps(
422
+ {
423
+ "benchmark_id": self.benchmark_id,
424
+ "benchmark_revision": self.benchmark_revision,
425
+ "task_id": task_id,
426
+ "instruction": instruction,
427
+ "initial_workspace_digest": workspace_digest,
428
+ },
429
+ ensure_ascii=False,
430
+ separators=(",", ":"),
431
+ sort_keys=True,
432
+ ).encode("utf-8")
433
+ context_payload = {
434
+ "kind": "benchmark_task",
435
+ "benchmark_id": self.benchmark_id,
436
+ "benchmark_revision": self.benchmark_revision,
437
+ "task_id": task_id,
438
+ "task_digest": "sha256:" + hashlib.sha256(task_digest_input).hexdigest(),
439
+ "verifier_identity": self.verifier_identity,
440
+ }
441
+ parent_payload = {
442
+ "kind": "eval",
443
+ "eval_id": self.eval_id,
444
+ "trial_id": trial_id,
445
+ "attempt": attempt,
446
+ }
199
447
  temporary: Path | None = None
448
+ context_temporary: Path | None = None
449
+ parent_temporary: Path | None = None
200
450
  try:
201
451
  with tempfile.NamedTemporaryFile(
202
452
  mode="w",
@@ -210,9 +460,18 @@ class HitchHarborAgent(BaseAgent):
210
460
  temporary = Path(handle.name)
211
461
  remote_instruction = "/tmp/hitch-instruction.txt"
212
462
  await environment.upload_file(temporary, remote_instruction)
463
+ context_temporary = self._temporary_json("hitch-context-", context_payload)
464
+ await environment.upload_file(context_temporary, "/tmp/hitch-context.json")
465
+ if parent_payload is not None:
466
+ parent_temporary = self._temporary_json("hitch-parent-", parent_payload)
467
+ await environment.upload_file(parent_temporary, "/tmp/hitch-parent.json")
213
468
  finally:
214
469
  if temporary is not None:
215
470
  temporary.unlink(missing_ok=True)
471
+ if context_temporary is not None:
472
+ context_temporary.unlink(missing_ok=True)
473
+ if parent_temporary is not None:
474
+ parent_temporary.unlink(missing_ok=True)
216
475
 
217
476
  if self._entrypoint is None:
218
477
  raise RuntimeError("Hitch agent setup() must run before run() to resolve the runtime entrypoint")
@@ -220,20 +479,30 @@ class HitchHarborAgent(BaseAgent):
220
479
  arguments = [
221
480
  self._node_prefix(),
222
481
  "HITCH_ROOT=/tmp/hitch-state",
482
+ *(["HITCH_HARBOR_INTERNAL=1"] if self._local_manifest is not None or parent_payload is not None else []),
223
483
  f"node {entry} run",
224
484
  "--harness",
225
485
  shlex.quote(self.harness_ref),
486
+ *self._local_source_cli_args(),
226
487
  "--cwd",
227
488
  shlex.quote(self.workdir),
228
489
  "--workspace-mode",
229
490
  "shared",
230
491
  "--prompt-file",
231
492
  shlex.quote(remote_instruction),
493
+ "--context-file",
494
+ "/tmp/hitch-context.json",
232
495
  "--timeout",
233
496
  str(self.hitch_timeout_ms),
234
497
  "--output",
235
498
  "jsonl",
236
499
  ]
500
+ if parent_payload is not None:
501
+ arguments.extend([
502
+ "--parent-file", "/tmp/hitch-parent.json",
503
+ "--internal-run-id", run_id,
504
+ "--internal-defer-benchmark-observation",
505
+ ])
237
506
  if self.model_name:
238
507
  arguments.extend(["--model", shlex.quote(self.model_name)])
239
508
  for value in self.agent_args:
@@ -246,7 +515,9 @@ class HitchHarborAgent(BaseAgent):
246
515
  )
247
516
  execution = await environment.exec(command, cwd=self.workdir)
248
517
  events = self._events(execution.stdout or "")
249
- run_id = next((event.get("run_id") for event in events if event.get("run_id")), None)
518
+ observed_run_id = next((event.get("run_id") for event in events if event.get("run_id")), None)
519
+ if observed_run_id:
520
+ run_id = str(observed_run_id)
250
521
  hitch_result = None
251
522
  if run_id:
252
523
  result_path = f"/tmp/hitch-state/runs/{run_id}/result.json"
@@ -255,15 +526,44 @@ class HitchHarborAgent(BaseAgent):
255
526
  )
256
527
  if result.return_code == 0 and result.stdout:
257
528
  hitch_result = json.loads(result.stdout)
529
+ export = await environment.exec(
530
+ f"""
531
+ set -eu
532
+ source_dir={shlex.quote(f'/tmp/hitch-state/runs/{run_id}')}
533
+ target_dir=/logs/agent/hitch-run-bundle
534
+ rm -rf "$target_dir"
535
+ mkdir -p "$target_dir"
536
+ for name in request.json resolution.json manifest.json result.json events.jsonl stdout.log stderr.log trajectory.ref.json trajectory; do
537
+ if [ -e "$source_dir/$name" ]; then cp -a "$source_dir/$name" "$target_dir/$name"; fi
538
+ done
539
+ """.strip()
540
+ )
541
+ if export.return_code != 0:
542
+ raise RuntimeError("Hitch run bundle export failed before trial teardown")
258
543
  context.metadata = {
259
544
  "candidate_id": self.candidate_id,
260
545
  "harness_ref": self.harness_ref,
261
546
  "revision_identity": self.revision_identity,
262
547
  "controller_runtime_id": self.controller_runtime_id,
263
548
  "hitch_run_id": run_id,
549
+ "hitch_run_bundle": "hitch-run-bundle",
550
+ "eval_id": self.eval_id,
551
+ "trial_id": trial_id,
552
+ "task_id": task_id,
553
+ "attempt": attempt,
264
554
  "hitch_status": hitch_result.get("status") if hitch_result else None,
265
555
  "hitch_artifact_id": hitch_result.get("artifact_id") if hitch_result else None,
266
556
  }
557
+ if self._local_manifest is not None:
558
+ context.metadata["local_source_transport"] = {
559
+ "kind": self._local_manifest["kind"],
560
+ "commit": self._local_manifest["commit"],
561
+ "tree": self._local_manifest["tree"],
562
+ "resolution_identity": self._local_manifest["resolution_identity"],
563
+ "payload_sha256": self._local_manifest["payload_sha256"],
564
+ "payload_bytes": self._local_manifest["payload_bytes"],
565
+ "status": "verified",
566
+ }
267
567
  if execution.return_code != 0:
268
568
  message = (execution.stderr or "").strip()
269
569
  if hitch_result and hitch_result.get("error", {}).get("message"):
@@ -279,6 +579,80 @@ class HitchHarborAgent(BaseAgent):
279
579
  f"expected {self.revision_identity}, got {hitch_result.get('revision_identity')}"
280
580
  )
281
581
 
582
+ def _trial_identity(self) -> tuple[str, str, int]:
583
+ """Read Harbor's stable trial/task identity from the persisted trial state."""
584
+ trial_dir = self.logs_dir.parent if self.logs_dir.name == "agent" else self.logs_dir
585
+ trial_id = trial_dir.name or "trial__1"
586
+ task_id = self._locked_task_id(trial_dir)
587
+ match = re.fullmatch(r"(.+)__(\d+)", trial_id)
588
+ if match:
589
+ return trial_id, task_id or match.group(1), max(1, int(match.group(2)))
590
+ # Harbor 0.21 uses a random seven-character suffix and truncates the
591
+ # task portion of trial_name. It therefore cannot be used to recover
592
+ # task identity; the trial lock above is authoritative. Keep the split
593
+ # only as a compatibility fallback for older/fake Harbor runtimes.
594
+ fallback_task_id = trial_id.rsplit("__", 1)[0] if "__" in trial_id else trial_id
595
+ return trial_id, task_id or fallback_task_id, 1
596
+
597
+ @staticmethod
598
+ def _locked_task_id(trial_dir: Path) -> str | None:
599
+ lock_path = trial_dir / "lock.json"
600
+ if not lock_path.is_file():
601
+ return None
602
+ try:
603
+ lock = json.loads(lock_path.read_text(encoding="utf-8"))
604
+ except (OSError, UnicodeError, json.JSONDecodeError) as error:
605
+ raise RuntimeError(f"Harbor trial lock is unreadable: {lock_path}") from error
606
+ task = lock.get("task") if isinstance(lock, dict) else None
607
+ task_id = task.get("name") if isinstance(task, dict) else None
608
+ if not isinstance(task_id, str) or not task_id.strip():
609
+ raise RuntimeError(f"Harbor trial lock has no task.name: {lock_path}")
610
+ return task_id.strip()
611
+
612
+ def _temporary_json(self, prefix: str, value: dict[str, Any]) -> Path:
613
+ with tempfile.NamedTemporaryFile(
614
+ mode="w",
615
+ encoding="utf-8",
616
+ prefix=prefix,
617
+ suffix=".json",
618
+ dir=self.logs_dir,
619
+ delete=False,
620
+ ) as handle:
621
+ json.dump(value, handle, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
622
+ handle.write("\n")
623
+ return Path(handle.name)
624
+
625
+ async def _workspace_digest(self, environment: BaseEnvironment) -> str:
626
+ script = r"""
627
+ const fs = require('node:fs');
628
+ const path = require('node:path');
629
+ const crypto = require('node:crypto');
630
+ const hash = crypto.createHash('sha256');
631
+ function walk(root, relative = '') {
632
+ const directory = path.join(root, relative);
633
+ for (const name of fs.readdirSync(directory).sort()) {
634
+ const child = relative ? path.join(relative, name) : name;
635
+ const absolute = path.join(root, child);
636
+ const info = fs.lstatSync(absolute);
637
+ if (info.isDirectory()) { hash.update(`d\0${child}\0${info.mode & 0o7777}\0`); walk(root, child); }
638
+ else if (info.isFile()) { hash.update(`f\0${child}\0${info.mode & 0o7777}\0${info.size}\0`); hash.update(fs.readFileSync(absolute)); hash.update('\0'); }
639
+ else if (info.isSymbolicLink()) { hash.update(`l\0${child}\0${fs.readlinkSync(absolute)}\0`); }
640
+ }
641
+ }
642
+ walk(process.argv[1]);
643
+ process.stdout.write('sha256:' + hash.digest('hex'));
644
+ """.strip()
645
+ result = await environment.exec(
646
+ " ".join([self._node_prefix(), "node", "-e", shlex.quote(script), shlex.quote(self.workdir)]),
647
+ cwd=self.workdir,
648
+ )
649
+ digest = (result.stdout or "").strip()
650
+ if result.return_code == 0 and re.fullmatch(r"sha256:[0-9a-f]{64}", digest):
651
+ return digest
652
+ # Older/fake Harbor environments may not expose a readable workspace
653
+ # during bridge tests. Keep a deterministic, visibly synthetic input.
654
+ return "sha256:" + hashlib.sha256(b"workspace-unavailable").hexdigest()
655
+
282
656
  async def _ensure_node(self, environment: BaseEnvironment) -> None:
283
657
  probe = await environment.exec(
284
658
  "node -e 'process.exit(Number(process.versions.node.split(\".\")[0]) >= 22 ? 0 : 1)'"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hitch",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Content-addressed version control and evidence storage for agent harnesses",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -18,6 +18,23 @@
18
18
  "url": "git+https://github.com/rsi-gear/agent-hitch.git"
19
19
  },
20
20
  "type": "module",
21
+ "exports": {
22
+ "./adapters": "./dist/src/adapters/index.js",
23
+ "./artifacts": "./dist/src/artifacts/index.js",
24
+ "./backends": "./dist/src/backends/index.js",
25
+ "./controller-runtime": "./dist/src/controller-runtime/index.js",
26
+ "./daemon": "./dist/src/daemon/index.js",
27
+ "./domain": "./dist/src/domain/index.js",
28
+ "./evals": "./dist/src/evals/index.js",
29
+ "./feedback": "./dist/src/feedback/index.js",
30
+ "./foundation": "./dist/src/foundation/index.js",
31
+ "./revisions": "./dist/src/revisions/index.js",
32
+ "./runs": "./dist/src/runs/index.js",
33
+ "./trajectories": "./dist/src/trajectories/index.js",
34
+ "./workspaces": "./dist/src/workspaces/index.js",
35
+ "./cli": "./dist/src/cli/index.js",
36
+ "./package.json": "./package.json"
37
+ },
21
38
  "bin": {
22
39
  "hitch": "dist/bin/hitch.js"
23
40
  },
@@ -30,10 +47,11 @@
30
47
  ],
31
48
  "scripts": {
32
49
  "typecheck": "tsc --noEmit",
33
- "build": "tsc -p tsconfig.json",
50
+ "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json",
34
51
  "test": "node --test \"dist/test/*.test.js\"",
35
52
  "coverage": "node --experimental-test-coverage --test-coverage-include=dist/src/**/*.js --test-coverage-lines=75 --test-coverage-branches=65 --test-coverage-functions=70 --test \"dist/test/*.test.js\"",
36
- "check": "npm run typecheck && npm run build && node dist/scripts/check-syntax.js && node --test \"dist/test/*.test.js\"",
53
+ "check:architecture": "node dist/scripts/check-architecture.js",
54
+ "check": "npm run typecheck && npm run build && npm run check:architecture && node dist/scripts/check-syntax.js && node --test \"dist/test/*.test.js\"",
37
55
  "prepublishOnly": "npm run check"
38
56
  },
39
57
  "engines": {