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