agent-hitch 0.1.1 → 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.
- package/README.md +235 -132
- package/README.zh-CN.md +335 -0
- package/dist/bin/hitch.js +9 -0
- package/dist/bin/hitch.js.map +1 -0
- package/dist/scripts/check-architecture.js +159 -0
- package/dist/scripts/check-architecture.js.map +1 -0
- package/dist/scripts/check-release.js +25 -0
- package/dist/scripts/check-release.js.map +1 -0
- package/dist/scripts/check-syntax.js +32 -0
- package/dist/scripts/check-syntax.js.map +1 -0
- package/dist/src/adapters/catalog.js +55 -0
- package/dist/src/adapters/catalog.js.map +1 -0
- package/dist/src/adapters/contract.js +2 -0
- package/dist/src/adapters/contract.js.map +1 -0
- package/dist/src/adapters/discovery.js +22 -0
- package/dist/src/adapters/discovery.js.map +1 -0
- package/dist/src/adapters/index.js +3 -0
- package/dist/src/adapters/index.js.map +1 -0
- package/dist/src/adapters/providers/claude.js +69 -0
- package/dist/src/adapters/providers/claude.js.map +1 -0
- package/dist/src/adapters/providers/codex.js +74 -0
- package/dist/src/adapters/providers/codex.js.map +1 -0
- package/dist/src/adapters/providers/deepseek.js +51 -0
- package/dist/src/adapters/providers/deepseek.js.map +1 -0
- package/dist/src/adapters/providers/opencode.js +73 -0
- package/dist/src/adapters/providers/opencode.js.map +1 -0
- package/dist/src/adapters/providers/pi.js +82 -0
- package/dist/src/adapters/providers/pi.js.map +1 -0
- package/dist/src/adapters/providers/shared.js +82 -0
- package/dist/src/adapters/providers/shared.js.map +1 -0
- package/dist/src/artifacts/index.js +3 -0
- package/dist/src/artifacts/index.js.map +1 -0
- package/dist/src/artifacts/integrity.js +188 -0
- package/dist/src/artifacts/integrity.js.map +1 -0
- package/dist/src/artifacts/preparer.js +379 -0
- package/dist/src/artifacts/preparer.js.map +1 -0
- package/dist/src/artifacts/store.js +87 -0
- package/dist/src/artifacts/store.js.map +1 -0
- package/dist/src/artifacts/types.js +2 -0
- package/dist/src/artifacts/types.js.map +1 -0
- package/dist/src/backends/contract.js +2 -0
- package/dist/src/backends/contract.js.map +1 -0
- package/dist/src/backends/harbor/backend.js +305 -0
- package/dist/src/backends/harbor/backend.js.map +1 -0
- package/dist/src/backends/harbor/index.js +4 -0
- package/dist/src/backends/harbor/index.js.map +1 -0
- package/dist/src/backends/harbor/local-git-transport.js +447 -0
- package/dist/src/backends/harbor/local-git-transport.js.map +1 -0
- package/dist/src/backends/harbor/tools.js +314 -0
- package/dist/src/backends/harbor/tools.js.map +1 -0
- package/dist/src/backends/index.js +4 -0
- package/dist/src/backends/index.js.map +1 -0
- package/dist/src/cli/arguments.js +123 -0
- package/dist/src/cli/arguments.js.map +1 -0
- package/dist/src/cli/commands/compare.js +55 -0
- package/dist/src/cli/commands/compare.js.map +1 -0
- package/dist/src/cli/commands/daemon.js +106 -0
- package/dist/src/cli/commands/daemon.js.map +1 -0
- package/dist/src/cli/commands/eval.js +126 -0
- package/dist/src/cli/commands/eval.js.map +1 -0
- package/dist/src/cli/commands/feedback.js +84 -0
- package/dist/src/cli/commands/feedback.js.map +1 -0
- package/dist/src/cli/commands/inspect.js +20 -0
- package/dist/src/cli/commands/inspect.js.map +1 -0
- package/dist/src/cli/commands/list.js +17 -0
- package/dist/src/cli/commands/list.js.map +1 -0
- package/dist/src/cli/commands/prepare.js +21 -0
- package/dist/src/cli/commands/prepare.js.map +1 -0
- package/dist/src/cli/commands/resolve.js +17 -0
- package/dist/src/cli/commands/resolve.js.map +1 -0
- package/dist/src/cli/commands/run.js +83 -0
- package/dist/src/cli/commands/run.js.map +1 -0
- package/dist/src/cli/commands/runs.js +45 -0
- package/dist/src/cli/commands/runs.js.map +1 -0
- package/dist/src/cli/commands/trajectory.js +31 -0
- package/dist/src/cli/commands/trajectory.js.map +1 -0
- package/dist/src/cli/commands/workspace.js +44 -0
- package/dist/src/cli/commands/workspace.js.map +1 -0
- package/dist/src/cli/index.js +2 -0
- package/dist/src/cli/index.js.map +1 -0
- package/dist/src/cli/main.js +48 -0
- package/dist/src/cli/main.js.map +1 -0
- package/dist/src/cli/output.js +96 -0
- package/dist/src/cli/output.js.map +1 -0
- package/dist/src/controller-runtime/hash.js +353 -0
- package/dist/src/controller-runtime/hash.js.map +1 -0
- package/dist/src/controller-runtime/index.js +3 -0
- package/dist/src/controller-runtime/index.js.map +1 -0
- package/dist/src/controller-runtime/store.js +318 -0
- package/dist/src/controller-runtime/store.js.map +1 -0
- package/dist/src/daemon/auth.js +108 -0
- package/dist/src/daemon/auth.js.map +1 -0
- package/dist/src/daemon/client.js +76 -0
- package/dist/src/daemon/client.js.map +1 -0
- package/dist/src/daemon/index.js +5 -0
- package/dist/src/daemon/index.js.map +1 -0
- package/dist/src/daemon/launcher.js +24 -0
- package/dist/src/daemon/launcher.js.map +1 -0
- package/dist/src/daemon/scheduler.js +161 -0
- package/dist/src/daemon/scheduler.js.map +1 -0
- package/dist/src/daemon/server.js +316 -0
- package/dist/src/daemon/server.js.map +1 -0
- package/dist/src/domain/artifacts.js +2 -0
- package/dist/src/domain/artifacts.js.map +1 -0
- package/dist/src/domain/controller-runtime.js +2 -0
- package/dist/src/domain/controller-runtime.js.map +1 -0
- package/dist/src/domain/eval-records.js +2 -0
- package/dist/src/domain/eval-records.js.map +1 -0
- package/dist/src/domain/evals.js +2 -0
- package/dist/src/domain/evals.js.map +1 -0
- package/dist/src/domain/feedback.js +2 -0
- package/dist/src/domain/feedback.js.map +1 -0
- package/dist/src/domain/ids.js +10 -0
- package/dist/src/domain/ids.js.map +1 -0
- package/dist/src/domain/index.js +11 -0
- package/dist/src/domain/index.js.map +1 -0
- package/dist/src/domain/revisions.js +2 -0
- package/dist/src/domain/revisions.js.map +1 -0
- package/dist/src/domain/runs.js +3 -0
- package/dist/src/domain/runs.js.map +1 -0
- package/dist/src/domain/trajectories.js +2 -0
- package/dist/src/domain/trajectories.js.map +1 -0
- package/dist/src/domain/validation.js +429 -0
- package/dist/src/domain/validation.js.map +1 -0
- package/dist/src/domain/workspaces.js +2 -0
- package/dist/src/domain/workspaces.js.map +1 -0
- package/dist/src/evals/events.js +67 -0
- package/dist/src/evals/events.js.map +1 -0
- package/dist/src/evals/index.js +5 -0
- package/dist/src/evals/index.js.map +1 -0
- package/dist/src/evals/records.js +55 -0
- package/dist/src/evals/records.js.map +1 -0
- package/dist/src/evals/request.js +109 -0
- package/dist/src/evals/request.js.map +1 -0
- package/dist/src/evals/service.js +227 -0
- package/dist/src/evals/service.js.map +1 -0
- package/dist/src/evals/trial-import.js +394 -0
- package/dist/src/evals/trial-import.js.map +1 -0
- package/dist/src/feedback/index.js +2 -0
- package/dist/src/feedback/index.js.map +1 -0
- package/dist/src/feedback/service.js +314 -0
- package/dist/src/feedback/service.js.map +1 -0
- package/dist/src/foundation/config.js +54 -0
- package/dist/src/foundation/config.js.map +1 -0
- package/dist/src/foundation/errors.js +14 -0
- package/dist/src/foundation/errors.js.map +1 -0
- package/dist/src/foundation/executable.js +135 -0
- package/dist/src/foundation/executable.js.map +1 -0
- package/dist/src/foundation/fs.js +45 -0
- package/dist/src/foundation/fs.js.map +1 -0
- package/dist/src/foundation/hash.js +24 -0
- package/dist/src/foundation/hash.js.map +1 -0
- package/dist/src/foundation/index.js +11 -0
- package/dist/src/foundation/index.js.map +1 -0
- package/dist/src/foundation/line-stream.js +20 -0
- package/dist/src/foundation/line-stream.js.map +1 -0
- package/dist/src/foundation/locks.js +114 -0
- package/dist/src/foundation/locks.js.map +1 -0
- package/dist/src/foundation/package-root.js +41 -0
- package/dist/src/foundation/package-root.js.map +1 -0
- package/dist/src/foundation/process.js +50 -0
- package/dist/src/foundation/process.js.map +1 -0
- package/dist/src/revisions/index.js +4 -0
- package/dist/src/revisions/index.js.map +1 -0
- package/dist/src/revisions/reference.js +97 -0
- package/dist/src/revisions/reference.js.map +1 -0
- package/dist/src/revisions/resolver.js +25 -0
- package/dist/src/revisions/resolver.js.map +1 -0
- package/dist/src/revisions/sources/git.js +180 -0
- package/dist/src/revisions/sources/git.js.map +1 -0
- package/dist/src/revisions/sources/installed.js +45 -0
- package/dist/src/revisions/sources/installed.js.map +1 -0
- package/dist/src/revisions/sources/npm.js +97 -0
- package/dist/src/revisions/sources/npm.js.map +1 -0
- package/dist/src/runs/compare.js +132 -0
- package/dist/src/runs/compare.js.map +1 -0
- package/dist/src/runs/events.js +79 -0
- package/dist/src/runs/events.js.map +1 -0
- package/dist/src/runs/executor.js +480 -0
- package/dist/src/runs/executor.js.map +1 -0
- package/dist/src/runs/finalizer.js +22 -0
- package/dist/src/runs/finalizer.js.map +1 -0
- package/dist/src/runs/identity.js +35 -0
- package/dist/src/runs/identity.js.map +1 -0
- package/dist/src/runs/index.js +11 -0
- package/dist/src/runs/index.js.map +1 -0
- package/dist/src/runs/manifest.js +70 -0
- package/dist/src/runs/manifest.js.map +1 -0
- package/dist/src/runs/outcome.js +49 -0
- package/dist/src/runs/outcome.js.map +1 -0
- package/dist/src/runs/query.js +104 -0
- package/dist/src/runs/query.js.map +1 -0
- package/dist/src/runs/queued.js +54 -0
- package/dist/src/runs/queued.js.map +1 -0
- package/dist/src/runs/records.js +288 -0
- package/dist/src/runs/records.js.map +1 -0
- package/dist/src/runs/request.js +169 -0
- package/dist/src/runs/request.js.map +1 -0
- package/dist/src/trajectories/contract.js +17 -0
- package/dist/src/trajectories/contract.js.map +1 -0
- package/dist/src/trajectories/format.js +127 -0
- package/dist/src/trajectories/format.js.map +1 -0
- package/dist/src/trajectories/index.js +7 -0
- package/dist/src/trajectories/index.js.map +1 -0
- package/dist/src/trajectories/projector.js +385 -0
- package/dist/src/trajectories/projector.js.map +1 -0
- package/dist/src/trajectories/provider-capture.js +133 -0
- package/dist/src/trajectories/provider-capture.js.map +1 -0
- package/dist/src/trajectories/providers/deepseek.js +179 -0
- package/dist/src/trajectories/providers/deepseek.js.map +1 -0
- package/dist/src/trajectories/store.js +421 -0
- package/dist/src/trajectories/store.js.map +1 -0
- package/dist/src/workspaces/copy.js +142 -0
- package/dist/src/workspaces/copy.js.map +1 -0
- package/dist/src/workspaces/digest.js +47 -0
- package/dist/src/workspaces/digest.js.map +1 -0
- package/dist/src/workspaces/git.js +164 -0
- package/dist/src/workspaces/git.js.map +1 -0
- package/dist/src/workspaces/index.js +6 -0
- package/dist/src/workspaces/index.js.map +1 -0
- package/dist/src/workspaces/lifecycle.js +313 -0
- package/dist/src/workspaces/lifecycle.js.map +1 -0
- package/dist/src/workspaces/planner.js +80 -0
- package/dist/src/workspaces/planner.js.map +1 -0
- package/dist/src/workspaces/store.js +25 -0
- package/dist/src/workspaces/store.js.map +1 -0
- package/dist/src/workspaces/types.js +4 -0
- package/dist/src/workspaces/types.js.map +1 -0
- package/dist/src/workspaces/utils.js +56 -0
- package/dist/src/workspaces/utils.js.map +1 -0
- package/docs/schemas/controller-runtime-manifest.schema.json +57 -0
- package/docs/schemas/controller-runtime-ref.schema.json +16 -0
- package/docs/schemas/eval-request.schema.json +2 -0
- package/docs/schemas/eval-result.schema.json +36 -1
- package/docs/schemas/local-git-transport-manifest.schema.json +34 -0
- package/docs/schemas/message-feedback.schema.json +37 -0
- package/docs/schemas/result.schema.json +9 -0
- package/docs/schemas/run-context.schema.json +39 -0
- package/docs/schemas/run-manifest.schema.json +81 -0
- package/docs/schemas/run-request.schema.json +16 -1
- package/docs/schemas/trajectory-ref.schema.json +73 -0
- package/integrations/harbor/hitch_harbor_agent.py +551 -5
- package/package.json +37 -11
- package/bin/hitch.js +0 -9
- package/src/adapters.js +0 -435
- package/src/artifacts.js +0 -949
- package/src/cli.js +0 -505
- package/src/config.js +0 -51
- package/src/daemon.js +0 -416
- package/src/engine.js +0 -400
- package/src/errors.js +0 -12
- package/src/eval-tools.js +0 -334
- package/src/evals.js +0 -276
- package/src/events.js +0 -69
- package/src/fs.js +0 -43
- package/src/harbor-backend.js +0 -285
- package/src/harness-reference.js +0 -78
- package/src/line-stream.js +0 -17
- package/src/locks.js +0 -33
- package/src/process.js +0 -49
- package/src/registry.js +0 -84
- package/src/scheduler.js +0 -156
- package/src/workspaces.js +0 -893
|
@@ -2,9 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import hashlib
|
|
5
6
|
import json
|
|
7
|
+
import re
|
|
6
8
|
import shlex
|
|
9
|
+
import stat as stat_module
|
|
7
10
|
import tempfile
|
|
11
|
+
import uuid
|
|
12
|
+
from datetime import datetime
|
|
8
13
|
from pathlib import Path
|
|
9
14
|
from typing import Any
|
|
10
15
|
|
|
@@ -12,6 +17,11 @@ from harbor.agents.base import BaseAgent
|
|
|
12
17
|
from harbor.environments.base import BaseEnvironment, ExecResult
|
|
13
18
|
from harbor.models.agent.context import AgentContext
|
|
14
19
|
|
|
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"
|
|
24
|
+
|
|
15
25
|
|
|
16
26
|
class HitchHarborAgent(BaseAgent):
|
|
17
27
|
"""Upload Hitch into a Harbor environment and delegate the agent phase to it."""
|
|
@@ -23,20 +33,34 @@ class HitchHarborAgent(BaseAgent):
|
|
|
23
33
|
revision_identity: str,
|
|
24
34
|
hitch_runtime_dir: str,
|
|
25
35
|
candidate_id: str = "candidate-1",
|
|
36
|
+
controller_runtime_id: str | None = None,
|
|
37
|
+
local_source_transport: dict[str, Any] | None = None,
|
|
26
38
|
hitch_timeout_ms: int = 900_000,
|
|
27
39
|
agent_args: list[str] | None = None,
|
|
28
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,
|
|
29
45
|
**kwargs: Any,
|
|
30
46
|
) -> None:
|
|
31
47
|
super().__init__(logs_dir=logs_dir, **kwargs)
|
|
32
48
|
self.harness_ref = harness_ref
|
|
33
49
|
self.revision_identity = revision_identity
|
|
34
50
|
self.hitch_runtime_dir = Path(hitch_runtime_dir)
|
|
51
|
+
self.controller_runtime_id = controller_runtime_id
|
|
52
|
+
self.local_source_transport = dict(local_source_transport) if local_source_transport else None
|
|
35
53
|
self.candidate_id = candidate_id
|
|
36
54
|
self.hitch_timeout_ms = int(hitch_timeout_ms)
|
|
37
55
|
self.agent_args = list(agent_args or [])
|
|
38
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
|
|
39
61
|
self._hitch_version: str | None = None
|
|
62
|
+
self._entrypoint: str | None = None
|
|
63
|
+
self._local_manifest: dict[str, Any] | None = None
|
|
40
64
|
|
|
41
65
|
@staticmethod
|
|
42
66
|
def name() -> str:
|
|
@@ -48,21 +72,339 @@ class HitchHarborAgent(BaseAgent):
|
|
|
48
72
|
async def setup(self, environment: BaseEnvironment) -> None:
|
|
49
73
|
if not self.hitch_runtime_dir.is_dir():
|
|
50
74
|
raise RuntimeError(f"Hitch runtime directory does not exist: {self.hitch_runtime_dir}")
|
|
51
|
-
|
|
75
|
+
# The manifest declares the CLI entrypoint (relative to the upload
|
|
76
|
+
# root). The bridge must not hardcode the TypeScript build layout
|
|
77
|
+
# (spec §4.3): read the manifest and execute its declared entrypoint.
|
|
78
|
+
manifest_path = self.hitch_runtime_dir / "manifest.json"
|
|
79
|
+
if not manifest_path.is_file():
|
|
80
|
+
raise RuntimeError(f"Hitch runtime bundle has no manifest: {self.hitch_runtime_dir}")
|
|
81
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
82
|
+
if manifest.get("schema_version") != CONTROLLER_RUNTIME_MANIFEST_VERSION:
|
|
83
|
+
raise RuntimeError(
|
|
84
|
+
f"unsupported controller runtime manifest schema {manifest.get('schema_version')!r}; "
|
|
85
|
+
f"expected {CONTROLLER_RUNTIME_MANIFEST_VERSION!r}"
|
|
86
|
+
)
|
|
87
|
+
entrypoint = self._validate_entrypoint(manifest)
|
|
88
|
+
self._entrypoint = entrypoint
|
|
89
|
+
# Verify the bundle identity on the Python side before anything is
|
|
90
|
+
# uploaded: the declared runtime_id must match the identity the Harbor
|
|
91
|
+
# job pinned, and the payload must still match the manifest's declared
|
|
92
|
+
# digests (closing the TOCTOU window between the TS-side verification
|
|
93
|
+
# and the actual container upload, spec §4.6).
|
|
94
|
+
self._verify_manifest_identity(manifest)
|
|
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
|
|
101
|
+
payload_dir = self.hitch_runtime_dir / "payload"
|
|
102
|
+
if not payload_dir.is_dir():
|
|
103
|
+
raise RuntimeError(f"Hitch runtime bundle has no payload directory: {self.hitch_runtime_dir}")
|
|
104
|
+
# Upload the cached bundle's payload (package.json + dist/) as the
|
|
105
|
+
# package root under /opt/hitch; the local cache path is host-side
|
|
106
|
+
# bookkeeping and is not identity (spec §4.2).
|
|
107
|
+
await environment.upload_dir(payload_dir, "/opt/hitch")
|
|
52
108
|
await self._ensure_node(environment)
|
|
53
|
-
|
|
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
|
|
114
|
+
entry = self._remote_entry(entrypoint)
|
|
115
|
+
version = await self._exec(environment, f"{self._node_prefix()} node {entry} --version")
|
|
54
116
|
self._hitch_version = (version.stdout or "").strip() or None
|
|
55
117
|
prepare = " ".join(
|
|
56
118
|
[
|
|
57
119
|
self._node_prefix(),
|
|
58
120
|
"HITCH_ROOT=/tmp/hitch-state",
|
|
59
|
-
"
|
|
121
|
+
*(["HITCH_HARBOR_INTERNAL=1"] if self._local_manifest is not None else []),
|
|
122
|
+
f"node {entry} prepare",
|
|
60
123
|
shlex.quote(self.harness_ref),
|
|
124
|
+
*self._local_source_cli_args(),
|
|
61
125
|
"--json",
|
|
62
126
|
]
|
|
63
127
|
)
|
|
64
128
|
await self._exec(environment, prepare)
|
|
65
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
|
+
|
|
317
|
+
@staticmethod
|
|
318
|
+
def _remote_entry(entrypoint: str) -> str:
|
|
319
|
+
"""Shell-quote the full remote path so the entrypoint is always a
|
|
320
|
+
single argv word, even if a (digest-verified) manifest path contains
|
|
321
|
+
shell metacharacters (spec §4.3, §8.5)."""
|
|
322
|
+
return shlex.quote(f"/opt/hitch/{entrypoint}")
|
|
323
|
+
|
|
324
|
+
def _verify_manifest_identity(self, manifest: dict[str, Any]) -> None:
|
|
325
|
+
"""Assert the canonical digest equals the declared runtime_id and the
|
|
326
|
+
job-pinned controller_runtime_id (spec §4.4, §4.6)."""
|
|
327
|
+
expected_id = manifest.get("runtime_id")
|
|
328
|
+
if not isinstance(expected_id, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", expected_id):
|
|
329
|
+
raise RuntimeError("controller runtime manifest runtime_id is not a sha256 digest")
|
|
330
|
+
recomputed = "sha256:" + hashlib.sha256(
|
|
331
|
+
canonical_manifest_json(manifest).encode("utf-8")
|
|
332
|
+
).hexdigest()
|
|
333
|
+
if recomputed != expected_id:
|
|
334
|
+
raise RuntimeError(
|
|
335
|
+
f"controller runtime manifest digest mismatch: recomputed {recomputed}, declared {expected_id}"
|
|
336
|
+
)
|
|
337
|
+
if self.controller_runtime_id is not None and self.controller_runtime_id != expected_id:
|
|
338
|
+
raise RuntimeError(
|
|
339
|
+
f"controller runtime id mismatch: job pinned {self.controller_runtime_id}, manifest declares {expected_id}"
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
def _verify_payload(self, manifest: dict[str, Any]) -> None:
|
|
343
|
+
"""Re-hash the on-disk payload against the manifest's declared files
|
|
344
|
+
(sizes, executable bits, SHA-256 digests) before upload (spec §4.6)."""
|
|
345
|
+
files = manifest.get("files")
|
|
346
|
+
if not isinstance(files, list):
|
|
347
|
+
raise RuntimeError("controller runtime manifest is missing files")
|
|
348
|
+
payload_dir = self.hitch_runtime_dir / "payload"
|
|
349
|
+
for file in files:
|
|
350
|
+
if not isinstance(file, dict):
|
|
351
|
+
raise RuntimeError("controller runtime manifest file entry is not an object")
|
|
352
|
+
rel = file.get("path")
|
|
353
|
+
size = file.get("size")
|
|
354
|
+
digest = file.get("sha256")
|
|
355
|
+
if not isinstance(rel, str) or not isinstance(size, int) or not isinstance(digest, str):
|
|
356
|
+
raise RuntimeError("controller runtime manifest file entry is malformed")
|
|
357
|
+
target = (payload_dir / rel).resolve()
|
|
358
|
+
if not str(target).startswith(str(payload_dir.resolve())):
|
|
359
|
+
raise RuntimeError(f"controller runtime payload file escapes the payload: {rel}")
|
|
360
|
+
if not target.is_file():
|
|
361
|
+
raise RuntimeError(f"controller runtime payload file is missing: {rel}")
|
|
362
|
+
actual_size = target.stat().st_size
|
|
363
|
+
if actual_size != size:
|
|
364
|
+
raise RuntimeError(
|
|
365
|
+
f"controller runtime payload size mismatch for {rel}: expected {size}, got {actual_size}"
|
|
366
|
+
)
|
|
367
|
+
actual_digest = "sha256:" + hashlib.sha256(target.read_bytes()).hexdigest()
|
|
368
|
+
if actual_digest != digest:
|
|
369
|
+
raise RuntimeError(f"controller runtime payload digest mismatch for {rel}")
|
|
370
|
+
|
|
371
|
+
@staticmethod
|
|
372
|
+
def _validate_entrypoint(manifest: dict[str, Any]) -> str:
|
|
373
|
+
"""Return the declared CLI entrypoint, validated against the file set.
|
|
374
|
+
|
|
375
|
+
The path must be a declared regular file: not absolute, no traversal,
|
|
376
|
+
no backslashes, no control characters (spec §4.3). It must be one of
|
|
377
|
+
the manifest's files so a manifest can never point execution outside
|
|
378
|
+
the uploaded payload.
|
|
379
|
+
"""
|
|
380
|
+
entrypoints = manifest.get("entrypoints")
|
|
381
|
+
if not isinstance(entrypoints, dict):
|
|
382
|
+
raise RuntimeError("controller runtime manifest is missing entrypoints")
|
|
383
|
+
cli = entrypoints.get("cli")
|
|
384
|
+
if not isinstance(cli, dict):
|
|
385
|
+
raise RuntimeError("controller runtime manifest is missing entrypoints.cli")
|
|
386
|
+
if cli.get("launcher") != "node":
|
|
387
|
+
raise RuntimeError("controller runtime CLI launcher must be 'node'")
|
|
388
|
+
entrypoint = cli.get("path")
|
|
389
|
+
if not isinstance(entrypoint, str) or not entrypoint:
|
|
390
|
+
raise RuntimeError("controller runtime manifest CLI entrypoint must be a non-empty path")
|
|
391
|
+
if (
|
|
392
|
+
entrypoint.startswith("/")
|
|
393
|
+
or "\\" in entrypoint
|
|
394
|
+
or entrypoint.startswith("..")
|
|
395
|
+
or "/../" in f"/{entrypoint}"
|
|
396
|
+
):
|
|
397
|
+
raise RuntimeError(f"controller runtime manifest CLI entrypoint escapes the payload: {entrypoint}")
|
|
398
|
+
if any(ord(ch) < 0x20 for ch in entrypoint):
|
|
399
|
+
raise RuntimeError(f"controller runtime manifest CLI entrypoint contains control characters: {entrypoint!r}")
|
|
400
|
+
files = manifest.get("files")
|
|
401
|
+
if not isinstance(files, list):
|
|
402
|
+
raise RuntimeError("controller runtime manifest is missing files")
|
|
403
|
+
declared = {file.get("path") for file in files if isinstance(file, dict)}
|
|
404
|
+
if entrypoint not in declared:
|
|
405
|
+
raise RuntimeError(f"controller runtime manifest CLI entrypoint is not a declared file: {entrypoint}")
|
|
406
|
+
return entrypoint
|
|
407
|
+
|
|
66
408
|
async def run(
|
|
67
409
|
self,
|
|
68
410
|
instruction: str,
|
|
@@ -70,7 +412,41 @@ class HitchHarborAgent(BaseAgent):
|
|
|
70
412
|
context: AgentContext,
|
|
71
413
|
) -> None:
|
|
72
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
|
+
}
|
|
73
447
|
temporary: Path | None = None
|
|
448
|
+
context_temporary: Path | None = None
|
|
449
|
+
parent_temporary: Path | None = None
|
|
74
450
|
try:
|
|
75
451
|
with tempfile.NamedTemporaryFile(
|
|
76
452
|
mode="w",
|
|
@@ -84,27 +460,49 @@ class HitchHarborAgent(BaseAgent):
|
|
|
84
460
|
temporary = Path(handle.name)
|
|
85
461
|
remote_instruction = "/tmp/hitch-instruction.txt"
|
|
86
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")
|
|
87
468
|
finally:
|
|
88
469
|
if temporary is not None:
|
|
89
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)
|
|
90
475
|
|
|
476
|
+
if self._entrypoint is None:
|
|
477
|
+
raise RuntimeError("Hitch agent setup() must run before run() to resolve the runtime entrypoint")
|
|
478
|
+
entry = self._remote_entry(self._entrypoint)
|
|
91
479
|
arguments = [
|
|
92
480
|
self._node_prefix(),
|
|
93
481
|
"HITCH_ROOT=/tmp/hitch-state",
|
|
94
|
-
"
|
|
482
|
+
*(["HITCH_HARBOR_INTERNAL=1"] if self._local_manifest is not None or parent_payload is not None else []),
|
|
483
|
+
f"node {entry} run",
|
|
95
484
|
"--harness",
|
|
96
485
|
shlex.quote(self.harness_ref),
|
|
486
|
+
*self._local_source_cli_args(),
|
|
97
487
|
"--cwd",
|
|
98
488
|
shlex.quote(self.workdir),
|
|
99
489
|
"--workspace-mode",
|
|
100
490
|
"shared",
|
|
101
491
|
"--prompt-file",
|
|
102
492
|
shlex.quote(remote_instruction),
|
|
493
|
+
"--context-file",
|
|
494
|
+
"/tmp/hitch-context.json",
|
|
103
495
|
"--timeout",
|
|
104
496
|
str(self.hitch_timeout_ms),
|
|
105
497
|
"--output",
|
|
106
498
|
"jsonl",
|
|
107
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
|
+
])
|
|
108
506
|
if self.model_name:
|
|
109
507
|
arguments.extend(["--model", shlex.quote(self.model_name)])
|
|
110
508
|
for value in self.agent_args:
|
|
@@ -117,7 +515,9 @@ class HitchHarborAgent(BaseAgent):
|
|
|
117
515
|
)
|
|
118
516
|
execution = await environment.exec(command, cwd=self.workdir)
|
|
119
517
|
events = self._events(execution.stdout or "")
|
|
120
|
-
|
|
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)
|
|
121
521
|
hitch_result = None
|
|
122
522
|
if run_id:
|
|
123
523
|
result_path = f"/tmp/hitch-state/runs/{run_id}/result.json"
|
|
@@ -126,14 +526,44 @@ class HitchHarborAgent(BaseAgent):
|
|
|
126
526
|
)
|
|
127
527
|
if result.return_code == 0 and result.stdout:
|
|
128
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")
|
|
129
543
|
context.metadata = {
|
|
130
544
|
"candidate_id": self.candidate_id,
|
|
131
545
|
"harness_ref": self.harness_ref,
|
|
132
546
|
"revision_identity": self.revision_identity,
|
|
547
|
+
"controller_runtime_id": self.controller_runtime_id,
|
|
133
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,
|
|
134
554
|
"hitch_status": hitch_result.get("status") if hitch_result else None,
|
|
135
555
|
"hitch_artifact_id": hitch_result.get("artifact_id") if hitch_result else None,
|
|
136
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
|
+
}
|
|
137
567
|
if execution.return_code != 0:
|
|
138
568
|
message = (execution.stderr or "").strip()
|
|
139
569
|
if hitch_result and hitch_result.get("error", {}).get("message"):
|
|
@@ -149,6 +579,80 @@ class HitchHarborAgent(BaseAgent):
|
|
|
149
579
|
f"expected {self.revision_identity}, got {hitch_result.get('revision_identity')}"
|
|
150
580
|
)
|
|
151
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
|
+
|
|
152
656
|
async def _ensure_node(self, environment: BaseEnvironment) -> None:
|
|
153
657
|
probe = await environment.exec(
|
|
154
658
|
"node -e 'process.exit(Number(process.versions.node.split(\".\")[0]) >= 22 ? 0 : 1)'"
|
|
@@ -215,3 +719,45 @@ node -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 22 ? 0 : 1)
|
|
|
215
719
|
diagnostic = (result.stderr or result.stdout or "no output").strip()
|
|
216
720
|
raise RuntimeError(f"container setup command failed ({result.return_code}): {diagnostic}")
|
|
217
721
|
return result
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def canonical_manifest_json(manifest: dict[str, Any]) -> str:
|
|
725
|
+
"""Canonically encode the runtime identity `{ schema_version, node_range,
|
|
726
|
+
entrypoints, files }` with sorted object keys and no insignificant
|
|
727
|
+
whitespace, mirroring the TypeScript `canonicalEncodeManifest` (spec §4.4.6).
|
|
728
|
+
`created_at` is descriptive and excluded from the identity."""
|
|
729
|
+
files = manifest.get("files")
|
|
730
|
+
if not isinstance(files, list):
|
|
731
|
+
raise RuntimeError("controller runtime manifest is missing files")
|
|
732
|
+
sorted_files = sorted(
|
|
733
|
+
files,
|
|
734
|
+
key=lambda f: (f.get("path") if isinstance(f, dict) else "").encode("utf-8"),
|
|
735
|
+
)
|
|
736
|
+
payload = {
|
|
737
|
+
"schema_version": manifest.get("schema_version"),
|
|
738
|
+
"node_range": manifest.get("node_range"),
|
|
739
|
+
"entrypoints": manifest.get("entrypoints"),
|
|
740
|
+
"files": [
|
|
741
|
+
{
|
|
742
|
+
"path": f.get("path"),
|
|
743
|
+
"size": f.get("size"),
|
|
744
|
+
"executable": f.get("executable"),
|
|
745
|
+
"sha256": f.get("sha256"),
|
|
746
|
+
}
|
|
747
|
+
for f in sorted_files
|
|
748
|
+
if isinstance(f, dict)
|
|
749
|
+
],
|
|
750
|
+
}
|
|
751
|
+
return _canonical_stringify(payload)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _canonical_stringify(value: Any) -> str:
|
|
755
|
+
if isinstance(value, list):
|
|
756
|
+
return "[" + ",".join(_canonical_stringify(item) for item in value) + "]"
|
|
757
|
+
if isinstance(value, dict):
|
|
758
|
+
keys = sorted(value.keys(), key=lambda k: k.encode("utf-8"))
|
|
759
|
+
return "{" + ",".join(
|
|
760
|
+
json.dumps(key, ensure_ascii=False) + ":" + _canonical_stringify(value[key])
|
|
761
|
+
for key in keys
|
|
762
|
+
) + "}"
|
|
763
|
+
return json.dumps(value, ensure_ascii=False)
|