agent-hitch 0.2.4 → 0.2.6
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.
- package/dist/src/adapters/providers/deepseek.js +3 -1
- package/dist/src/adapters/providers/deepseek.js.map +1 -1
- package/dist/src/adapters/providers/shared.js +3 -2
- package/dist/src/adapters/providers/shared.js.map +1 -1
- package/dist/src/backends/harbor/backend.js +21 -5
- package/dist/src/backends/harbor/backend.js.map +1 -1
- package/dist/src/backends/harbor/verifier-config.js +11 -0
- package/dist/src/backends/harbor/verifier-config.js.map +1 -0
- package/dist/src/cli/arguments.js +14 -0
- package/dist/src/cli/arguments.js.map +1 -1
- package/dist/src/cli/output.js +4 -1
- package/dist/src/cli/output.js.map +1 -1
- package/dist/src/evals/harbor-bridge-error.js +65 -0
- package/dist/src/evals/harbor-bridge-error.js.map +1 -0
- package/dist/src/evals/index.js +2 -2
- package/dist/src/evals/index.js.map +1 -1
- package/dist/src/evals/infrastructure-retry.js +181 -0
- package/dist/src/evals/infrastructure-retry.js.map +1 -0
- package/dist/src/evals/progress.js +3 -0
- package/dist/src/evals/progress.js.map +1 -1
- package/dist/src/evals/request.js +14 -1
- package/dist/src/evals/request.js.map +1 -1
- package/dist/src/evals/rerun-slots.js +103 -0
- package/dist/src/evals/rerun-slots.js.map +1 -0
- package/dist/src/evals/rerun.js +167 -148
- package/dist/src/evals/rerun.js.map +1 -1
- package/dist/src/evals/service.js +196 -84
- package/dist/src/evals/service.js.map +1 -1
- package/dist/src/evals/trial-import.js +52 -36
- package/dist/src/evals/trial-import.js.map +1 -1
- package/dist/src/evals/verifier-diagnostics.js +235 -0
- package/dist/src/evals/verifier-diagnostics.js.map +1 -0
- package/docs/schemas/eval-request.schema.json +2 -0
- package/integrations/harbor/hitch_harbor_agent.py +389 -40
- package/integrations/harbor/hitch_harbor_verifier.py +314 -0
- package/package.json +3 -2
|
@@ -28,6 +28,20 @@ LOCAL_GIT_TRANSPORT_MAX_BYTES = 512 * 1024 * 1024
|
|
|
28
28
|
LOCAL_GIT_REMOTE_ROOT = "/opt/hitch-local-source"
|
|
29
29
|
HARNESS_ARTIFACT_REMOTE_ROOT = "/opt/hitch-harness-artifact"
|
|
30
30
|
HITCH_CONTAINER_STATE_ROOT = "/tmp/hitch-state"
|
|
31
|
+
HITCH_BRIDGE_ERROR_LOG = "/logs/agent/hitch-bridge-error.json"
|
|
32
|
+
HITCH_DIAGNOSTIC_MAX_BYTES = 8 * 1024
|
|
33
|
+
HITCH_BRIDGE_ERROR_MAX_BYTES = 64 * 1024
|
|
34
|
+
HITCH_RESULT_MISSING_EXIT = 44
|
|
35
|
+
HITCH_RESULT_NOT_FILE_EXIT = 45
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class HitchBridgeError(RuntimeError):
|
|
39
|
+
"""Stable Harbor-facing infrastructure failure with structured evidence."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, code: str, message: str, evidence: dict[str, Any]) -> None:
|
|
42
|
+
super().__init__(f"{code}: {message}")
|
|
43
|
+
self.code = code
|
|
44
|
+
self.evidence = evidence
|
|
31
45
|
|
|
32
46
|
|
|
33
47
|
class HitchHarborAgent(BaseAgent):
|
|
@@ -46,11 +60,12 @@ class HitchHarborAgent(BaseAgent):
|
|
|
46
60
|
local_source_transport: dict[str, Any] | None = None,
|
|
47
61
|
hitch_timeout_ms: int = 900_000,
|
|
48
62
|
agent_args: list[str] | None = None,
|
|
49
|
-
workdir: str =
|
|
63
|
+
workdir: str | None = None,
|
|
50
64
|
eval_id: str | None = None,
|
|
51
65
|
benchmark_id: str | None = None,
|
|
52
66
|
benchmark_revision: str | None = None,
|
|
53
67
|
verifier_identity: str | None = None,
|
|
68
|
+
logical_attempt: int | None = None,
|
|
54
69
|
**kwargs: Any,
|
|
55
70
|
) -> None:
|
|
56
71
|
super().__init__(logs_dir=logs_dir, **kwargs)
|
|
@@ -64,11 +79,17 @@ class HitchHarborAgent(BaseAgent):
|
|
|
64
79
|
self.candidate_id = candidate_id
|
|
65
80
|
self.hitch_timeout_ms = int(hitch_timeout_ms)
|
|
66
81
|
self.agent_args = list(agent_args or [])
|
|
67
|
-
|
|
82
|
+
if workdir is not None and (not isinstance(workdir, str) or not workdir.strip()):
|
|
83
|
+
raise ValueError("workdir must be a non-empty string when provided")
|
|
84
|
+
self.workdir = workdir.strip() if isinstance(workdir, str) else None
|
|
85
|
+
self._workdir_source: str | None = "agent_config" if self.workdir is not None else None
|
|
68
86
|
self.eval_id = eval_id
|
|
69
87
|
self.benchmark_id = benchmark_id
|
|
70
88
|
self.benchmark_revision = benchmark_revision
|
|
71
89
|
self.verifier_identity = verifier_identity
|
|
90
|
+
if logical_attempt is not None and (isinstance(logical_attempt, bool) or not isinstance(logical_attempt, int) or logical_attempt < 1):
|
|
91
|
+
raise ValueError("logical_attempt must be a positive integer")
|
|
92
|
+
self.logical_attempt = logical_attempt
|
|
72
93
|
self._hitch_version: str | None = None
|
|
73
94
|
self._entrypoint: str | None = None
|
|
74
95
|
self._artifact_manifest: dict[str, Any] | None = None
|
|
@@ -127,6 +148,7 @@ class HitchHarborAgent(BaseAgent):
|
|
|
127
148
|
payload_dir = self.hitch_runtime_dir / "payload"
|
|
128
149
|
if not payload_dir.is_dir():
|
|
129
150
|
raise RuntimeError(f"Hitch runtime bundle has no payload directory: {self.hitch_runtime_dir}")
|
|
151
|
+
await self._resolve_workdir(environment)
|
|
130
152
|
# Upload the cached bundle's payload (package.json + dist/) as the
|
|
131
153
|
# package root under /opt/hitch; the local cache path is host-side
|
|
132
154
|
# bookkeeping and is not identity (spec §4.2).
|
|
@@ -175,6 +197,111 @@ class HitchHarborAgent(BaseAgent):
|
|
|
175
197
|
if cache_lock is not None:
|
|
176
198
|
await self._release_artifact_cache_lock(cache_lock)
|
|
177
199
|
|
|
200
|
+
async def _resolve_workdir(self, environment: BaseEnvironment) -> str:
|
|
201
|
+
"""Resolve Harbor's effective task directory and prove it is usable.
|
|
202
|
+
|
|
203
|
+
Harbor environments already combine task-level ``[environment].workdir``
|
|
204
|
+
with the container image's ``WORKDIR``. Honor an explicit bridge
|
|
205
|
+
override, then the task configuration, then ask the running container
|
|
206
|
+
for its default cwd instead of assuming a global path such as /app.
|
|
207
|
+
"""
|
|
208
|
+
candidate = self.workdir
|
|
209
|
+
source = self._workdir_source
|
|
210
|
+
task_config = getattr(environment, "task_env_config", None)
|
|
211
|
+
task_workdir = getattr(task_config, "workdir", None)
|
|
212
|
+
discovery: ExecResult | None = None
|
|
213
|
+
if candidate is None and isinstance(task_workdir, str) and task_workdir.strip():
|
|
214
|
+
candidate = task_workdir.strip()
|
|
215
|
+
source = "task_environment"
|
|
216
|
+
if candidate is None:
|
|
217
|
+
discovery = await environment.exec("pwd -P")
|
|
218
|
+
candidate = (discovery.stdout or "").strip()
|
|
219
|
+
source = "container_workdir"
|
|
220
|
+
if discovery.return_code != 0 or not candidate:
|
|
221
|
+
detail = self._exec_diagnostic(discovery)
|
|
222
|
+
await self._raise_workdir_error(
|
|
223
|
+
environment,
|
|
224
|
+
"Could not determine the Harbor task working directory from the container "
|
|
225
|
+
f"(exit={discovery.return_code}): {detail}",
|
|
226
|
+
source=source,
|
|
227
|
+
candidate=candidate or None,
|
|
228
|
+
probe=discovery,
|
|
229
|
+
)
|
|
230
|
+
if (
|
|
231
|
+
candidate is None
|
|
232
|
+
or not PurePosixPath(candidate).is_absolute()
|
|
233
|
+
or "\x00" in candidate
|
|
234
|
+
or "\n" in candidate
|
|
235
|
+
or "\r" in candidate
|
|
236
|
+
):
|
|
237
|
+
await self._raise_workdir_error(
|
|
238
|
+
environment,
|
|
239
|
+
f"Harbor task working directory must be an absolute POSIX path; got {candidate!r} "
|
|
240
|
+
f"from {source or 'unknown'}",
|
|
241
|
+
source=source,
|
|
242
|
+
candidate=candidate,
|
|
243
|
+
probe=discovery,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
exists = await environment.exec(f"test -d {shlex.quote(candidate)}", cwd="/")
|
|
247
|
+
if exists.return_code != 0:
|
|
248
|
+
detail = self._exec_diagnostic(exists)
|
|
249
|
+
await self._raise_workdir_error(
|
|
250
|
+
environment,
|
|
251
|
+
f"Harbor task working directory does not exist or is not a directory: {candidate} "
|
|
252
|
+
f"(source={source}, exit={exists.return_code}): {detail}",
|
|
253
|
+
source=source,
|
|
254
|
+
candidate=candidate,
|
|
255
|
+
probe=exists,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
usable = await environment.exec("pwd -P", cwd=candidate)
|
|
259
|
+
resolved = (usable.stdout or "").strip()
|
|
260
|
+
if usable.return_code != 0 or not resolved or not PurePosixPath(resolved).is_absolute():
|
|
261
|
+
detail = self._exec_diagnostic(usable)
|
|
262
|
+
await self._raise_workdir_error(
|
|
263
|
+
environment,
|
|
264
|
+
f"Harbor task working directory exists but cannot be used to start the Hitch agent: {candidate} "
|
|
265
|
+
f"(source={source}, exit={usable.return_code}): {detail}",
|
|
266
|
+
source=source,
|
|
267
|
+
candidate=candidate,
|
|
268
|
+
probe=usable,
|
|
269
|
+
)
|
|
270
|
+
self.workdir = resolved
|
|
271
|
+
self._workdir_source = source
|
|
272
|
+
return resolved
|
|
273
|
+
|
|
274
|
+
async def _raise_workdir_error(
|
|
275
|
+
self,
|
|
276
|
+
environment: BaseEnvironment,
|
|
277
|
+
message: str,
|
|
278
|
+
*,
|
|
279
|
+
source: str | None,
|
|
280
|
+
candidate: str | None,
|
|
281
|
+
probe: ExecResult | None,
|
|
282
|
+
) -> None:
|
|
283
|
+
evidence: dict[str, Any] = {
|
|
284
|
+
"schema_version": "1",
|
|
285
|
+
"code": "hitch_workdir_invalid",
|
|
286
|
+
"message": self._bounded_tail(message, 2048),
|
|
287
|
+
"recorded_at": datetime.now(timezone.utc).isoformat(),
|
|
288
|
+
"eval_id": self.eval_id,
|
|
289
|
+
"workdir": {
|
|
290
|
+
"source": source,
|
|
291
|
+
"candidate": candidate,
|
|
292
|
+
"return_code": probe.return_code if probe is not None else None,
|
|
293
|
+
"stdout_tail": self._bounded_tail(probe.stdout or "") if probe is not None else "",
|
|
294
|
+
"stderr_tail": self._bounded_tail(probe.stderr or "") if probe is not None else "",
|
|
295
|
+
},
|
|
296
|
+
}
|
|
297
|
+
await self._write_bridge_error(environment, evidence)
|
|
298
|
+
raise HitchBridgeError("hitch_workdir_invalid", message, evidence)
|
|
299
|
+
|
|
300
|
+
def _require_workdir(self) -> str:
|
|
301
|
+
if self.workdir is None:
|
|
302
|
+
raise RuntimeError("Hitch agent setup() must resolve the Harbor task working directory before run()")
|
|
303
|
+
return self.workdir
|
|
304
|
+
|
|
178
305
|
def _verify_harness_artifact_host(self) -> dict[str, Any]:
|
|
179
306
|
"""Pin host artifact metadata before Harbor copies the directory."""
|
|
180
307
|
transport = self.harness_artifact or {}
|
|
@@ -816,7 +943,9 @@ git -C {LOCAL_GIT_REMOTE_ROOT}/repo.git update-ref refs/heads/hitch-local {commi
|
|
|
816
943
|
context: AgentContext,
|
|
817
944
|
) -> None:
|
|
818
945
|
self.logs_dir.mkdir(parents=True, exist_ok=True)
|
|
819
|
-
|
|
946
|
+
workdir = self._require_workdir()
|
|
947
|
+
assigned_run_id = "run_" + uuid.uuid4().hex
|
|
948
|
+
run_id = assigned_run_id
|
|
820
949
|
trial_id, task_id, attempt = self._trial_identity()
|
|
821
950
|
context_payload: dict[str, Any] = {"kind": "ad_hoc"}
|
|
822
951
|
parent_payload: dict[str, Any] | None = None
|
|
@@ -890,7 +1019,7 @@ git -C {LOCAL_GIT_REMOTE_ROOT}/repo.git update-ref refs/heads/hitch-local {commi
|
|
|
890
1019
|
*self._local_source_cli_args(),
|
|
891
1020
|
*self._artifact_cli_args(),
|
|
892
1021
|
"--cwd",
|
|
893
|
-
shlex.quote(
|
|
1022
|
+
shlex.quote(workdir),
|
|
894
1023
|
"--workspace-mode",
|
|
895
1024
|
"shared",
|
|
896
1025
|
"--prompt-file",
|
|
@@ -918,29 +1047,39 @@ git -C {LOCAL_GIT_REMOTE_ROOT}/repo.git update-ref refs/heads/hitch-local {commi
|
|
|
918
1047
|
+ " 2> >(tee /logs/agent/hitch-stderr.log >&2)"
|
|
919
1048
|
+ " | tee /logs/agent/hitch-events.jsonl"
|
|
920
1049
|
)
|
|
921
|
-
execution = await environment.exec(command, cwd=
|
|
1050
|
+
execution = await environment.exec(command, cwd=workdir)
|
|
922
1051
|
events = self._events(execution.stdout or "")
|
|
923
|
-
observed_run_id = next((
|
|
1052
|
+
observed_run_id = next((
|
|
1053
|
+
value
|
|
1054
|
+
for event in events
|
|
1055
|
+
for value in [event.get("run_id")]
|
|
1056
|
+
if isinstance(value, str) and re.fullmatch(r"run_[a-f0-9]{32}", value)
|
|
1057
|
+
), None)
|
|
924
1058
|
if observed_run_id:
|
|
925
1059
|
run_id = str(observed_run_id)
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1060
|
+
result_path = f"/tmp/hitch-state/runs/{run_id}/result.json"
|
|
1061
|
+
quoted_result_path = shlex.quote(result_path)
|
|
1062
|
+
result_read = await environment.exec(
|
|
1063
|
+
f"""
|
|
1064
|
+
if [ ! -e {quoted_result_path} ]; then exit {HITCH_RESULT_MISSING_EXIT}; fi
|
|
1065
|
+
if [ -L {quoted_result_path} ] || [ ! -f {quoted_result_path} ]; then exit {HITCH_RESULT_NOT_FILE_EXIT}; fi
|
|
1066
|
+
cat -- {quoted_result_path}
|
|
1067
|
+
""".strip()
|
|
1068
|
+
)
|
|
1069
|
+
result_copy = await environment.exec(
|
|
1070
|
+
f"if [ -f {quoted_result_path} ] && [ ! -L {quoted_result_path} ]; then "
|
|
1071
|
+
f"cp -- {quoted_result_path} /logs/agent/hitch-result.json; fi"
|
|
1072
|
+
)
|
|
1073
|
+
bundle_stage = f"/logs/agent/.hitch-run-bundle.{uuid.uuid4().hex}"
|
|
1074
|
+
bundle_marker = json.dumps({
|
|
1075
|
+
"schema_version": "1",
|
|
1076
|
+
"run_id": run_id,
|
|
1077
|
+
"eval_id": self.eval_id,
|
|
1078
|
+
"trial_id": trial_id,
|
|
1079
|
+
"completed_at": datetime.now(timezone.utc).isoformat(),
|
|
1080
|
+
}, separators=(",", ":"))
|
|
1081
|
+
bundle_export = await environment.exec(
|
|
1082
|
+
f"""
|
|
944
1083
|
set -eu
|
|
945
1084
|
source_dir={shlex.quote(f'/tmp/hitch-state/runs/{run_id}')}
|
|
946
1085
|
target_dir=/logs/agent/hitch-run-bundle
|
|
@@ -954,9 +1093,38 @@ printf '%s\n' {shlex.quote(bundle_marker)} > "$stage_dir/bundle.complete.json"
|
|
|
954
1093
|
rm -rf "$target_dir"
|
|
955
1094
|
mv "$stage_dir" "$target_dir"
|
|
956
1095
|
""".strip()
|
|
1096
|
+
)
|
|
1097
|
+
hitch_result, result_error_code, result_error_message = self._parse_hitch_result(result_read, run_id)
|
|
1098
|
+
primary_code: str | None = None
|
|
1099
|
+
primary_message: str | None = None
|
|
1100
|
+
if execution.return_code != 0:
|
|
1101
|
+
primary_code = "hitch_process_failed"
|
|
1102
|
+
diagnostic = self._exec_diagnostic(execution)
|
|
1103
|
+
if hitch_result and isinstance(hitch_result.get("error"), dict):
|
|
1104
|
+
result_message = hitch_result["error"].get("message")
|
|
1105
|
+
if isinstance(result_message, str) and result_message.strip():
|
|
1106
|
+
diagnostic = result_message.strip()
|
|
1107
|
+
primary_message = (
|
|
1108
|
+
f"Hitch agent run failed with code {execution.return_code} "
|
|
1109
|
+
f"(run_id={run_id}, trial_id={trial_id}): {self._bounded_tail(diagnostic)}"
|
|
957
1110
|
)
|
|
958
|
-
|
|
959
|
-
|
|
1111
|
+
elif result_error_code is not None:
|
|
1112
|
+
primary_code = result_error_code
|
|
1113
|
+
primary_message = result_error_message
|
|
1114
|
+
elif hitch_result is not None and hitch_result.get("revision_identity") != self.revision_identity:
|
|
1115
|
+
primary_code = "hitch_revision_identity_mismatch"
|
|
1116
|
+
primary_message = (
|
|
1117
|
+
"Hitch resolved a different harness revision inside the trial container "
|
|
1118
|
+
f"(run_id={run_id}, trial_id={trial_id}): expected {self.revision_identity}, "
|
|
1119
|
+
f"got {hitch_result.get('revision_identity')}"
|
|
1120
|
+
)
|
|
1121
|
+
elif bundle_export.return_code != 0:
|
|
1122
|
+
primary_code = "hitch_run_bundle_export_failed"
|
|
1123
|
+
primary_message = f"Hitch run bundle export failed (run_id={run_id}, trial_id={trial_id})"
|
|
1124
|
+
elif result_copy.return_code != 0:
|
|
1125
|
+
primary_code = "hitch_result_artifact_copy_failed"
|
|
1126
|
+
primary_message = f"Hitch result artifact copy failed (run_id={run_id}, trial_id={trial_id})"
|
|
1127
|
+
|
|
960
1128
|
context.metadata = {
|
|
961
1129
|
"candidate_id": self.candidate_id,
|
|
962
1130
|
"harness_ref": self.harness_ref,
|
|
@@ -964,6 +1132,8 @@ mv "$stage_dir" "$target_dir"
|
|
|
964
1132
|
"controller_runtime_id": self.controller_runtime_id,
|
|
965
1133
|
"hitch_run_id": run_id,
|
|
966
1134
|
"hitch_run_bundle": "hitch-run-bundle",
|
|
1135
|
+
"hitch_workdir": workdir,
|
|
1136
|
+
"hitch_workdir_source": self._workdir_source,
|
|
967
1137
|
"eval_id": self.eval_id,
|
|
968
1138
|
"trial_id": trial_id,
|
|
969
1139
|
"task_id": task_id,
|
|
@@ -989,26 +1159,197 @@ mv "$stage_dir" "$target_dir"
|
|
|
989
1159
|
"node_version": self._artifact_manifest.get("toolchain", {}).get("node"),
|
|
990
1160
|
"status": self._artifact_transport_status or "container_prepare",
|
|
991
1161
|
}
|
|
992
|
-
if
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1162
|
+
if primary_code is not None:
|
|
1163
|
+
context.metadata["hitch_bridge_error_code"] = primary_code
|
|
1164
|
+
context.metadata["hitch_bridge_error_artifact"] = "hitch-bridge-error.json"
|
|
1165
|
+
evidence = self._bridge_error_evidence(
|
|
1166
|
+
code=primary_code,
|
|
1167
|
+
message=primary_message or primary_code,
|
|
1168
|
+
trial_id=trial_id,
|
|
1169
|
+
task_id=task_id,
|
|
1170
|
+
attempt=attempt,
|
|
1171
|
+
assigned_run_id=assigned_run_id,
|
|
1172
|
+
observed_run_id=str(observed_run_id) if observed_run_id else None,
|
|
1173
|
+
result_path=result_path,
|
|
1174
|
+
execution=execution,
|
|
1175
|
+
result_read=result_read,
|
|
1176
|
+
result_diagnostic=result_error_code,
|
|
1177
|
+
last_event=events[-1] if events else None,
|
|
1178
|
+
bundle_export=bundle_export,
|
|
1179
|
+
result_copy=result_copy,
|
|
998
1180
|
)
|
|
999
|
-
|
|
1000
|
-
raise
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1181
|
+
await self._write_bridge_error(environment, evidence)
|
|
1182
|
+
raise HitchBridgeError(primary_code, primary_message or primary_code, evidence)
|
|
1183
|
+
|
|
1184
|
+
@staticmethod
|
|
1185
|
+
def _parse_hitch_result(
|
|
1186
|
+
result: ExecResult,
|
|
1187
|
+
expected_run_id: str,
|
|
1188
|
+
) -> tuple[dict[str, Any] | None, str | None, str | None]:
|
|
1189
|
+
if result.return_code != 0:
|
|
1190
|
+
if result.return_code == HITCH_RESULT_MISSING_EXIT:
|
|
1191
|
+
return None, "hitch_result_missing", f"Hitch result file is missing (run_id={expected_run_id})"
|
|
1192
|
+
if result.return_code == HITCH_RESULT_NOT_FILE_EXIT:
|
|
1193
|
+
return None, "hitch_result_not_file", f"Hitch result path is not a regular file (run_id={expected_run_id})"
|
|
1194
|
+
return None, "hitch_result_read_failed", (
|
|
1195
|
+
f"Hitch result file could not be read with code {result.return_code} (run_id={expected_run_id})"
|
|
1005
1196
|
)
|
|
1197
|
+
payload = (result.stdout or "").strip()
|
|
1198
|
+
if not payload:
|
|
1199
|
+
return None, "hitch_result_empty", f"Hitch result file is empty (run_id={expected_run_id})"
|
|
1200
|
+
try:
|
|
1201
|
+
value = json.loads(payload)
|
|
1202
|
+
except json.JSONDecodeError:
|
|
1203
|
+
return None, "hitch_result_invalid_json", f"Hitch result is not valid JSON (run_id={expected_run_id})"
|
|
1204
|
+
if not isinstance(value, dict):
|
|
1205
|
+
return None, "hitch_result_schema_invalid", f"Hitch result must be a JSON object (run_id={expected_run_id})"
|
|
1206
|
+
error = HitchHarborAgent._result_schema_error(value)
|
|
1207
|
+
if error is not None:
|
|
1208
|
+
return None, "hitch_result_schema_invalid", f"Hitch result schema is invalid: {error} (run_id={expected_run_id})"
|
|
1209
|
+
if value["run_id"] != expected_run_id:
|
|
1210
|
+
return None, "hitch_result_run_id_mismatch", (
|
|
1211
|
+
f"Hitch result run id mismatch: expected {expected_run_id}, got {value['run_id']}"
|
|
1212
|
+
)
|
|
1213
|
+
return value, None, None
|
|
1214
|
+
|
|
1215
|
+
@staticmethod
|
|
1216
|
+
def _result_schema_error(value: dict[str, Any]) -> str | None:
|
|
1217
|
+
if value.get("schema_version") != "1":
|
|
1218
|
+
return "schema_version must be '1'"
|
|
1219
|
+
run_id = value.get("run_id")
|
|
1220
|
+
if not isinstance(run_id, str) or re.fullmatch(r"run_[a-f0-9]{32}", run_id) is None:
|
|
1221
|
+
return "run_id is invalid"
|
|
1222
|
+
if value.get("status") not in {"succeeded", "failed", "timed_out", "cancelled"}:
|
|
1223
|
+
return "status is invalid"
|
|
1224
|
+
exit_code = value.get("exit_code")
|
|
1225
|
+
if isinstance(exit_code, bool) or not isinstance(exit_code, int) or exit_code < 0:
|
|
1226
|
+
return "exit_code must be a non-negative integer"
|
|
1227
|
+
completed_at = value.get("completed_at")
|
|
1228
|
+
if not isinstance(completed_at, str) or not completed_at.strip():
|
|
1229
|
+
return "completed_at must be a non-empty string"
|
|
1230
|
+
if "error" in value:
|
|
1231
|
+
error = value["error"]
|
|
1232
|
+
if not isinstance(error, dict):
|
|
1233
|
+
return "error must be an object"
|
|
1234
|
+
if not isinstance(error.get("code"), str) or not isinstance(error.get("message"), str):
|
|
1235
|
+
return "error.code and error.message must be strings"
|
|
1236
|
+
return None
|
|
1237
|
+
|
|
1238
|
+
def _bridge_error_evidence(
|
|
1239
|
+
self,
|
|
1240
|
+
*,
|
|
1241
|
+
code: str,
|
|
1242
|
+
message: str,
|
|
1243
|
+
trial_id: str,
|
|
1244
|
+
task_id: str,
|
|
1245
|
+
attempt: int,
|
|
1246
|
+
assigned_run_id: str,
|
|
1247
|
+
observed_run_id: str | None,
|
|
1248
|
+
result_path: str,
|
|
1249
|
+
execution: ExecResult,
|
|
1250
|
+
result_read: ExecResult,
|
|
1251
|
+
result_diagnostic: str | None,
|
|
1252
|
+
last_event: dict[str, Any] | None,
|
|
1253
|
+
bundle_export: ExecResult,
|
|
1254
|
+
result_copy: ExecResult,
|
|
1255
|
+
) -> dict[str, Any]:
|
|
1256
|
+
signal_value = getattr(execution, "signal", None)
|
|
1257
|
+
evidence: dict[str, Any] = {
|
|
1258
|
+
"schema_version": "1",
|
|
1259
|
+
"code": code,
|
|
1260
|
+
"message": self._bounded_tail(message, 2048),
|
|
1261
|
+
"recorded_at": datetime.now(timezone.utc).isoformat(),
|
|
1262
|
+
"eval_id": self.eval_id,
|
|
1263
|
+
"trial_id": trial_id,
|
|
1264
|
+
"task_id": task_id,
|
|
1265
|
+
"attempt": attempt,
|
|
1266
|
+
"assigned_run_id": assigned_run_id,
|
|
1267
|
+
"observed_run_id": observed_run_id,
|
|
1268
|
+
"result_path": result_path,
|
|
1269
|
+
"process": {
|
|
1270
|
+
"return_code": execution.return_code,
|
|
1271
|
+
"signal": signal_value if isinstance(signal_value, str) else None,
|
|
1272
|
+
"stdout_tail": self._bounded_tail(execution.stdout or ""),
|
|
1273
|
+
"stderr_tail": self._bounded_tail(execution.stderr or ""),
|
|
1274
|
+
},
|
|
1275
|
+
"result_read": {
|
|
1276
|
+
"return_code": result_read.return_code,
|
|
1277
|
+
"stdout_tail": self._bounded_tail(result_read.stdout or ""),
|
|
1278
|
+
"stderr_tail": self._bounded_tail(result_read.stderr or ""),
|
|
1279
|
+
},
|
|
1280
|
+
"last_event": self._bounded_event(last_event),
|
|
1281
|
+
"result_diagnostic": result_diagnostic,
|
|
1282
|
+
}
|
|
1283
|
+
if bundle_export.return_code != 0:
|
|
1284
|
+
evidence["bundle_export"] = {
|
|
1285
|
+
"return_code": bundle_export.return_code,
|
|
1286
|
+
"stdout_tail": self._bounded_tail(bundle_export.stdout or ""),
|
|
1287
|
+
"stderr_tail": self._bounded_tail(bundle_export.stderr or ""),
|
|
1288
|
+
}
|
|
1289
|
+
if result_copy.return_code != 0:
|
|
1290
|
+
evidence["result_copy"] = {
|
|
1291
|
+
"return_code": result_copy.return_code,
|
|
1292
|
+
"stdout_tail": self._bounded_tail(result_copy.stdout or ""),
|
|
1293
|
+
"stderr_tail": self._bounded_tail(result_copy.stderr or ""),
|
|
1294
|
+
}
|
|
1295
|
+
if len(json.dumps(evidence, ensure_ascii=False, separators=(",", ":")).encode("utf-8")) > HITCH_BRIDGE_ERROR_MAX_BYTES:
|
|
1296
|
+
evidence["message"] = self._bounded_tail(str(evidence["message"]), 1024)
|
|
1297
|
+
evidence["last_event"] = self._bounded_event_summary(last_event)
|
|
1298
|
+
for section_name in ["process", "result_read", "bundle_export", "result_copy"]:
|
|
1299
|
+
section = evidence.get(section_name)
|
|
1300
|
+
if not isinstance(section, dict):
|
|
1301
|
+
continue
|
|
1302
|
+
for field in ["stdout_tail", "stderr_tail"]:
|
|
1303
|
+
section[field] = self._bounded_tail(str(section.get(field, "")), 2048)
|
|
1304
|
+
evidence["diagnostics_truncated"] = True
|
|
1305
|
+
return evidence
|
|
1306
|
+
|
|
1307
|
+
@staticmethod
|
|
1308
|
+
def _bounded_tail(value: str, max_bytes: int = HITCH_DIAGNOSTIC_MAX_BYTES) -> str:
|
|
1309
|
+
encoded = value.encode("utf-8", errors="replace")
|
|
1310
|
+
if len(encoded) <= max_bytes:
|
|
1311
|
+
return value
|
|
1312
|
+
marker = f"[truncated {len(encoded) - max_bytes} bytes]\n".encode("utf-8")
|
|
1313
|
+
available = max(0, max_bytes - len(marker))
|
|
1314
|
+
suffix = encoded[-available:] if available else b""
|
|
1315
|
+
suffix = suffix.decode("utf-8", errors="ignore").encode("utf-8")
|
|
1316
|
+
return (marker + suffix).decode("utf-8")
|
|
1317
|
+
|
|
1318
|
+
@staticmethod
|
|
1319
|
+
def _bounded_event(event: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
1320
|
+
if event is None:
|
|
1321
|
+
return None
|
|
1322
|
+
encoded = json.dumps(event, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
1323
|
+
if len(encoded) <= HITCH_DIAGNOSTIC_MAX_BYTES:
|
|
1324
|
+
return event
|
|
1325
|
+
return HitchHarborAgent._bounded_event_summary(event)
|
|
1326
|
+
|
|
1327
|
+
@staticmethod
|
|
1328
|
+
def _bounded_event_summary(event: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
1329
|
+
if event is None:
|
|
1330
|
+
return None
|
|
1331
|
+
return {
|
|
1332
|
+
"type": event.get("type"),
|
|
1333
|
+
"run_id": event.get("run_id"),
|
|
1334
|
+
"truncated": True,
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
@staticmethod
|
|
1338
|
+
async def _write_bridge_error(environment: BaseEnvironment, evidence: dict[str, Any]) -> None:
|
|
1339
|
+
payload = json.dumps(evidence, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
|
1340
|
+
await environment.exec(
|
|
1341
|
+
f"umask 077; printf '%s\\n' {shlex.quote(payload)} > {HITCH_BRIDGE_ERROR_LOG}",
|
|
1342
|
+
cwd="/",
|
|
1343
|
+
)
|
|
1006
1344
|
|
|
1007
1345
|
def _trial_identity(self) -> tuple[str, str, int]:
|
|
1008
1346
|
"""Read Harbor's stable trial/task identity from the persisted trial state."""
|
|
1009
1347
|
trial_dir = self.logs_dir.parent if self.logs_dir.name == "agent" else self.logs_dir
|
|
1010
1348
|
trial_id = trial_dir.name or "trial__1"
|
|
1011
1349
|
task_id = self._locked_task_id(trial_dir)
|
|
1350
|
+
if self.logical_attempt is not None:
|
|
1351
|
+
fallback_task_id = trial_id.rsplit("__", 1)[0] if "__" in trial_id else trial_id
|
|
1352
|
+
return trial_id, task_id or fallback_task_id, self.logical_attempt
|
|
1012
1353
|
match = re.fullmatch(r"(.+)__(\d+)", trial_id)
|
|
1013
1354
|
if match:
|
|
1014
1355
|
return trial_id, task_id or match.group(1), max(1, int(match.group(2)))
|
|
@@ -1048,6 +1389,7 @@ mv "$stage_dir" "$target_dir"
|
|
|
1048
1389
|
return Path(handle.name)
|
|
1049
1390
|
|
|
1050
1391
|
async def _workspace_digest(self, environment: BaseEnvironment) -> str:
|
|
1392
|
+
workdir = self._require_workdir()
|
|
1051
1393
|
script = r"""
|
|
1052
1394
|
const fs = require('node:fs');
|
|
1053
1395
|
const path = require('node:path');
|
|
@@ -1068,8 +1410,8 @@ walk(process.argv[1]);
|
|
|
1068
1410
|
process.stdout.write('sha256:' + hash.digest('hex'));
|
|
1069
1411
|
""".strip()
|
|
1070
1412
|
result = await environment.exec(
|
|
1071
|
-
" ".join([self._node_prefix(), "node", "-e", shlex.quote(script), shlex.quote(
|
|
1072
|
-
cwd=
|
|
1413
|
+
" ".join([self._node_prefix(), "node", "-e", shlex.quote(script), shlex.quote(workdir)]),
|
|
1414
|
+
cwd=workdir,
|
|
1073
1415
|
)
|
|
1074
1416
|
digest = (result.stdout or "").strip()
|
|
1075
1417
|
if result.return_code == 0 and re.fullmatch(r"sha256:[0-9a-f]{64}", digest):
|
|
@@ -1145,6 +1487,13 @@ node -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 22 ? 0 : 1)
|
|
|
1145
1487
|
raise RuntimeError(f"container setup command failed ({result.return_code}): {diagnostic}")
|
|
1146
1488
|
return result
|
|
1147
1489
|
|
|
1490
|
+
@staticmethod
|
|
1491
|
+
def _exec_diagnostic(result: ExecResult) -> str:
|
|
1492
|
+
for value in (result.stderr, result.stdout):
|
|
1493
|
+
if value and value.strip():
|
|
1494
|
+
return value.strip()
|
|
1495
|
+
return "no diagnostic output"
|
|
1496
|
+
|
|
1148
1497
|
|
|
1149
1498
|
def canonical_manifest_json(manifest: dict[str, Any]) -> str:
|
|
1150
1499
|
"""Canonically encode the runtime identity `{ schema_version, node_range,
|