From 7b58dbb8aa2bf6d3a93e66b3c8d552538c06fb92 Mon Sep 17 00:00:00 2001
From: luyd
Date: Tue, 27 Oct 2026 11:41:50 +0800
Subject: [PATCH] Integrate sandbox deployment and configuration workflows
Import the snapshot from 2773d45512fc1e9b9c492c9cc1b3c7e3f0188600 without source history, based on the existing local main.
Include controller and rollout services, OpenSandbox and H-cluster adapters, configuration tools, local web console, and native train/inference consistency. Exclude the standalone Transformers math trainer, its OPD helper, reference gate, examples, tests, and guide.
---
.github/workflows/ci.yml | 29 +
.gitignore | 15 +-
CONTRIBUTING.md | 19 +
NOTICE.md | 4 +
README.md | 22 +-
README_zh.md | 11 +
docs/configuration-wizard.md | 140 +
docs/deployment.md | 110 +
docs/sandbox-rollout.md | 72 +
docs/train-inference-consistency.md | 79 +
docs/web-console.md | 110 +
examples/native/catalog.jsonl | 1 +
examples/native/deployment-embedded.yaml | 45 +
examples/native/deployment-service.yaml | 43 +
examples/native/sandbox.yaml | 10 +
examples/options/embedded-local.answers.yaml | 30 +
examples/options/service-sandbox.answers.yaml | 34 +
examples/options/service-ssh.answers.yaml | 44 +
harborrl/algorithms/__init__.py | 1 +
harborrl/algorithms/grpo.py | 18 +
harborrl/backends/slime_v032/config.py | 125 +
harborrl/backends/slime_v032/converter.py | 2 +
harborrl/backends/slime_v032/launcher.py | 35 +-
harborrl/backends/slime_v032/loss.py | 7 +
harborrl/backends/slime_v032/postprocess.py | 31 +-
harborrl/backends/slime_v032/rollout.py | 164 +-
.../slime_v032/rollout_service_client.py | 210 ++
harborrl/cli.py | 52 +-
harborrl/config/native.py | 183 +-
harborrl/consistency/__init__.py | 1 +
harborrl/consistency/actor.py | 88 +
harborrl/consistency/config.py | 51 +
harborrl/consistency/metrics.py | 67 +
harborrl/consistency/probe.py | 35 +
harborrl/consistency/report.py | 51 +
harborrl/consistency/sglang.py | 92 +
harborrl/consistency/slime.py | 74 +
harborrl/consistency/train.py | 76 +
harborrl/data/harbor/__main__.py | 1 +
harborrl/data/harbor/inspector.py | 25 +-
harborrl/data/harbor/materializer.py | 2 +-
harborrl/data/harbor/native_inspector.py | 6 +-
harborrl/data/harbor/probe.py | 9 +-
harborrl/data/harbor/receipt.py | 2 +-
harborrl/deployment/__init__.py | 1 +
harborrl/deployment/controller.py | 207 ++
harborrl/deployment/preflight.py | 340 ++
harborrl/deployment/remote.py | 55 +
harborrl/deployment/schema.py | 330 ++
harborrl/deployment/training_target.py | 217 ++
harborrl/gateway/messages.py | 24 +-
harborrl/gateway/server.py | 2 +-
harborrl/gateway/sglang_backend.py | 17 +-
harborrl/gateway/trace.py | 20 +
harborrl/harnesses/__init__.py | 1 +
harborrl/harnesses/claude_code.py | 53 +
harborrl/harnesses/offline_claude.py | 61 +
harborrl/math_registry.py | 316 ++
harborrl/models/__init__.py | 1 +
harborrl/models/qwen.py | 39 +
harborrl/options/__init__.py | 11 +
harborrl/options/catalog.py | 147 +
harborrl/options/cli.py | 184 +
harborrl/options/presets.py | 153 +
harborrl/options/probe.py | 39 +
harborrl/options/resources/datasets.yaml | 40 +
harborrl/options/validation.py | 169 +
harborrl/options/wizard.py | 68 +
harborrl/platform/evidence.py | 126 +
harborrl/platform/native_train.py | 281 +-
harborrl/platform/policy_pool.py | 2 +
harborrl/platform/preflight.py | 220 ++
harborrl/platform/slime_train.sh | 11 +-
harborrl/rollout/exporters/native.py | 63 +-
harborrl/rollout/harbor_job/__main__.py | 6 +-
harborrl/rollout/harbor_job/bindings.py | 12 +-
harborrl/rollout/harbor_job/coordinator.py | 34 +-
.../harbor_job/environments/__init__.py | 1 +
.../harbor_job/environments/opensandbox.py | 189 +
harborrl/rollout/harbor_job/offline.py | 6 +-
harborrl/rollout/harbor_job/runner.py | 38 +-
harborrl/rollout/harbor_job/sandbox_config.py | 135 +
harborrl/rollout/harbor_job/sandbox_probe.py | 63 +
.../rollout/harbor_job/sandbox_transport.py | 27 +
harborrl/rollout/native_generate.py | 272 +-
harborrl/rollout/runtime.py | 350 ++
harborrl/rollout/service.py | 562 +++
harborrl/tasks/catalog.py | 8 +
harborrl/tasks/cli.py | 6 +-
harborrl/tasks/content.py | 26 +
harborrl/tasks/jsonl_provider.py | 92 +
harborrl/tasks/provider.py | 72 +
harborrl/webui/__init__.py | 1 +
harborrl/webui/cli.py | 67 +
harborrl/webui/jobs.py | 173 +
harborrl/webui/server.py | 189 +
harborrl/webui/static/app.js | 884 +++++
harborrl/webui/static/brand.jpg | Bin 0 -> 186959 bytes
harborrl/webui/static/index.html | 273 ++
harborrl/webui/static/lucide-LICENSE.txt | 43 +
harborrl/webui/static/lucide.min.js | 12 +
harborrl/webui/static/style.css | 1094 ++++++
practices/h_cluster/README.md | 100 +
.../h_cluster/configs/deployment.example.yaml | 37 +
.../h_cluster/configs/sandbox.example.yaml | 23 +
practices/h_cluster/pyproject.toml | 16 +
.../src/harborrl_h_cluster/__init__.py | 1 +
.../src/harborrl_h_cluster/environment.py | 143 +
.../h_cluster/src/harborrl_h_cluster/relay.py | 50 +
.../src/harborrl_h_cluster/relay_server.py | 150 +
.../src/harborrl_h_cluster/reverse_relay.py | 49 +
.../src/harborrl_h_cluster/transport.py | 28 +
practices/h_cluster/tests/test_environment.py | 69 +
practices/h_cluster/tests/test_relay.py | 137 +
practices/h_cluster/tests/test_transport.py | 66 +
pyproject.toml | 8 +
scripts/audit_public_tree.sh | 51 +-
scripts/collect_service_evidence.py | 102 +
scripts/readiness_dial.py | 91 +
scripts/rollout_service_smoke.py | 263 ++
scripts/sandbox_sdk_contract.py | 60 +
scripts/sandbox_smoke.py | 65 +
scripts/sync_options_manifest.py | 22 +
tests/harborrl/test_delivery_barrier.py | 41 +
tests/harborrl/test_deployment_config.py | 176 +
tests/harborrl/test_deployment_preflight.py | 241 ++
tests/harborrl/test_deployment_targets.py | 235 ++
tests/harborrl/test_math_interfaces.py | 126 +
tests/harborrl/test_native_gateway.py | 65 +-
tests/harborrl/test_native_lifecycle.py | 5 +
tests/harborrl/test_native_terminal.py | 111 +
tests/harborrl/test_native_training.py | 255 +-
tests/harborrl/test_options.py | 308 ++
tests/harborrl/test_rollout_runtime.py | 321 ++
tests/harborrl/test_rollout_service.py | 415 +++
tests/harborrl/test_sandbox.py | 97 +
.../test_sandbox_allocation_transport.py | 24 +
tests/harborrl/test_sandbox_cleanup.py | 36 +
tests/harborrl/test_sandbox_portability.py | 57 +
tests/harborrl/test_sandbox_runtime.py | 92 +
tests/harborrl/test_sandbox_scale.py | 36 +
.../test_service_group_concurrency.py | 63 +
tests/harborrl/test_slime_v032_exporter.py | 84 +-
tests/harborrl/test_webui.py | 244 ++
tests/test_consistency.py | 81 +
tests/test_consistency_torch.py | 31 +
uv.lock | 3256 ++++++++++++++++-
147 files changed, 16964 insertions(+), 1026 deletions(-)
create mode 100644 docs/configuration-wizard.md
create mode 100644 docs/deployment.md
create mode 100644 docs/sandbox-rollout.md
create mode 100644 docs/train-inference-consistency.md
create mode 100644 docs/web-console.md
create mode 100644 examples/native/catalog.jsonl
create mode 100644 examples/native/deployment-embedded.yaml
create mode 100644 examples/native/deployment-service.yaml
create mode 100644 examples/native/sandbox.yaml
create mode 100644 examples/options/embedded-local.answers.yaml
create mode 100644 examples/options/service-sandbox.answers.yaml
create mode 100644 examples/options/service-ssh.answers.yaml
create mode 100644 harborrl/algorithms/__init__.py
create mode 100644 harborrl/algorithms/grpo.py
create mode 100644 harborrl/backends/slime_v032/config.py
create mode 100644 harborrl/backends/slime_v032/rollout_service_client.py
create mode 100644 harborrl/consistency/__init__.py
create mode 100644 harborrl/consistency/actor.py
create mode 100644 harborrl/consistency/config.py
create mode 100644 harborrl/consistency/metrics.py
create mode 100644 harborrl/consistency/probe.py
create mode 100644 harborrl/consistency/report.py
create mode 100644 harborrl/consistency/sglang.py
create mode 100644 harborrl/consistency/slime.py
create mode 100644 harborrl/consistency/train.py
create mode 100644 harborrl/deployment/__init__.py
create mode 100644 harborrl/deployment/controller.py
create mode 100644 harborrl/deployment/preflight.py
create mode 100644 harborrl/deployment/remote.py
create mode 100644 harborrl/deployment/schema.py
create mode 100644 harborrl/deployment/training_target.py
create mode 100644 harborrl/harnesses/__init__.py
create mode 100644 harborrl/harnesses/claude_code.py
create mode 100644 harborrl/harnesses/offline_claude.py
create mode 100644 harborrl/math_registry.py
create mode 100644 harborrl/models/__init__.py
create mode 100644 harborrl/models/qwen.py
create mode 100644 harborrl/options/__init__.py
create mode 100644 harborrl/options/catalog.py
create mode 100644 harborrl/options/cli.py
create mode 100644 harborrl/options/presets.py
create mode 100644 harborrl/options/probe.py
create mode 100644 harborrl/options/resources/datasets.yaml
create mode 100644 harborrl/options/validation.py
create mode 100644 harborrl/options/wizard.py
create mode 100644 harborrl/platform/evidence.py
create mode 100644 harborrl/platform/preflight.py
create mode 100644 harborrl/rollout/harbor_job/environments/__init__.py
create mode 100644 harborrl/rollout/harbor_job/environments/opensandbox.py
create mode 100644 harborrl/rollout/harbor_job/sandbox_config.py
create mode 100644 harborrl/rollout/harbor_job/sandbox_probe.py
create mode 100644 harborrl/rollout/harbor_job/sandbox_transport.py
create mode 100644 harborrl/rollout/runtime.py
create mode 100644 harborrl/rollout/service.py
create mode 100644 harborrl/tasks/catalog.py
create mode 100644 harborrl/tasks/content.py
create mode 100644 harborrl/tasks/jsonl_provider.py
create mode 100644 harborrl/tasks/provider.py
create mode 100644 harborrl/webui/__init__.py
create mode 100644 harborrl/webui/cli.py
create mode 100644 harborrl/webui/jobs.py
create mode 100644 harborrl/webui/server.py
create mode 100644 harborrl/webui/static/app.js
create mode 100644 harborrl/webui/static/brand.jpg
create mode 100644 harborrl/webui/static/index.html
create mode 100644 harborrl/webui/static/lucide-LICENSE.txt
create mode 100644 harborrl/webui/static/lucide.min.js
create mode 100644 harborrl/webui/static/style.css
create mode 100644 practices/h_cluster/README.md
create mode 100644 practices/h_cluster/configs/deployment.example.yaml
create mode 100644 practices/h_cluster/configs/sandbox.example.yaml
create mode 100644 practices/h_cluster/pyproject.toml
create mode 100644 practices/h_cluster/src/harborrl_h_cluster/__init__.py
create mode 100644 practices/h_cluster/src/harborrl_h_cluster/environment.py
create mode 100644 practices/h_cluster/src/harborrl_h_cluster/relay.py
create mode 100644 practices/h_cluster/src/harborrl_h_cluster/relay_server.py
create mode 100644 practices/h_cluster/src/harborrl_h_cluster/reverse_relay.py
create mode 100644 practices/h_cluster/src/harborrl_h_cluster/transport.py
create mode 100644 practices/h_cluster/tests/test_environment.py
create mode 100644 practices/h_cluster/tests/test_relay.py
create mode 100644 practices/h_cluster/tests/test_transport.py
create mode 100755 scripts/collect_service_evidence.py
create mode 100755 scripts/readiness_dial.py
create mode 100755 scripts/rollout_service_smoke.py
create mode 100644 scripts/sandbox_sdk_contract.py
create mode 100644 scripts/sandbox_smoke.py
create mode 100644 scripts/sync_options_manifest.py
create mode 100644 tests/harborrl/test_delivery_barrier.py
create mode 100644 tests/harborrl/test_deployment_config.py
create mode 100644 tests/harborrl/test_deployment_preflight.py
create mode 100644 tests/harborrl/test_deployment_targets.py
create mode 100644 tests/harborrl/test_math_interfaces.py
create mode 100644 tests/harborrl/test_native_terminal.py
create mode 100644 tests/harborrl/test_options.py
create mode 100644 tests/harborrl/test_rollout_runtime.py
create mode 100644 tests/harborrl/test_rollout_service.py
create mode 100644 tests/harborrl/test_sandbox.py
create mode 100644 tests/harborrl/test_sandbox_allocation_transport.py
create mode 100644 tests/harborrl/test_sandbox_cleanup.py
create mode 100644 tests/harborrl/test_sandbox_portability.py
create mode 100644 tests/harborrl/test_sandbox_runtime.py
create mode 100644 tests/harborrl/test_sandbox_scale.py
create mode 100644 tests/harborrl/test_service_group_concurrency.py
create mode 100644 tests/harborrl/test_webui.py
create mode 100644 tests/test_consistency.py
create mode 100644 tests/test_consistency_torch.py
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 280801786..c8c90eead 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,6 +31,35 @@ jobs:
- name: Public tree audit
run: bash scripts/audit_public_tree.sh
+ documentation-history:
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - name: Audit documents in every new commit
+ env:
+ DOCUMENTATION_BASE: ${{ github.event.pull_request.base.sha }}
+ run: bash scripts/audit_public_tree.sh --history-base "$DOCUMENTATION_BASE"
+
+ sandbox-contracts:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - name: Install optional runtime and practice
+ run: python -m pip install -e '.[dev,sandbox]' -e practices/h_cluster
+ - name: SDK contracts without allocation
+ run: python scripts/sandbox_sdk_contract.py
+ - name: Optional lifecycle and practice tests
+ run: python -m pytest tests/harborrl/test_sandbox_cleanup.py practices/h_cluster/tests
+
package:
runs-on: ubuntu-latest
needs: cpu
diff --git a/.gitignore b/.gitignore
index 2f4e7f480..7755b672b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,7 +32,20 @@ harbor_datasets/
.tools/
.worktrees/
benchmarks/harbor/0.5-mvp/
-docs/
+docs/*
+!docs/agents/
+docs/agents/*
+!docs/agents/environment-check.md
+!docs/configuration-wizard.md
+!docs/deployment.md
+!docs/sandbox-rollout.md
+!docs/train-inference-consistency.md
+!docs/web-console.md
+practices/**/VALIDATION.md
+practices/**/HISTORICAL_VALIDATION.md
+integration-report-*.md
+integration-roadmap-*.md
+*.bundle
local/
configs/site/
examples/**/private*
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 98b03e866..46f7e2791 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -13,6 +13,25 @@ HarborRL accepts changes through pull requests from a public branch.
6. Submit a reviewable commit series and describe external versions, task
provenance, and any acceptance evidence.
+## Public documentation
+
+Commit documentation that helps users install, configure, run or troubleshoot
+HarborRL. Keep development plans, integration reports, experiment logs and
+machine-specific validation records outside the repository. Summarize current
+support and limitations in the relevant user guide without development history.
+
+The public-document allowlist is maintained in `scripts/audit_public_tree.sh`.
+Update it deliberately when adding a user guide. Check both the final tree and
+every new commit before opening a pull request:
+
+```bash
+bash scripts/audit_public_tree.sh --history-base origin/main
+```
+
+Deleting a private document in a later commit does not remove it from history.
+Clean unpublished commits before pushing; do not merge a development branch
+whose history contains private documents into a public branch.
+
Configuration and task fixtures must use generic placeholders. Absolute personal
paths, internal DNS names, private registry references, and unexplained large
files will fail review.
diff --git a/NOTICE.md b/NOTICE.md
index 4dc4f09ac..3ab8ecf6f 100644
--- a/NOTICE.md
+++ b/NOTICE.md
@@ -2,6 +2,10 @@
HarborRL is MIT-licensed by Princeton-AI.
+The local web console bundles Lucide 1.61.0 (ISC license). Its unmodified browser
+distribution and license are in `harborrl/webui/static/lucide.min.js` and
+`harborrl/webui/static/lucide-LICENSE.txt`. No CDN connection is required.
+
The public package does not vendor Slime, Megatron-LM, SGLang, Harbor, Claude
Code, or model weights. Operators are responsible for reviewing the licenses of
the exact external versions they pin and for complying with model and dataset
diff --git a/README.md b/README.md
index cc82cddd7..1b1b8ac7d 100644
--- a/README.md
+++ b/README.md
@@ -2,9 +2,9 @@
-
+
A lightweight agentic reinforcement learning framework for everyone
@@ -25,6 +25,8 @@
## News
+- **2026-10-26** Added an [offline configuration wizard](docs/configuration-wizard.md) and a [local Web console](docs/web-console.md) for configuration, validation, dry-runs, training launch, and logs.
+- **2026-10-26** Added [controller/GPU deployment configuration](docs/deployment.md), optional [OpenSandbox execution](docs/sandbox-rollout.md), and opt-in [train/inference consistency checks](docs/train-inference-consistency.md).
- **10/2026** 📣📣📣 HarborRL released
## About
@@ -40,6 +42,14 @@ HarborRL supports flexible combinations of **Models × Algorithms × Tasks × Ha
## AI-assisted Configure
+For the offline options catalog, interactive configuration, and answer-file setup,
+see the [configuration CLI guide](docs/configuration-wizard.md).
+Start with `harborrl options` or `harborrl init --help`.
+
+Run `harborrl web` to open the local browser console for configuration, validation,
+dry-runs, training launch, and logs. See the [web console guide](docs/web-console.md)
+for remote-controller access and lifecycle details.
+
If you want an AI agent to help deploy this repository and verify training,
ask it to first read and strictly follow
[docs/agents/environment-check.md](docs/agents/environment-check.md).
@@ -78,6 +88,12 @@ Confirm the result yourself against this checklist:
## Quick Start
+The default CPU install does not need a Sandbox SDK or cluster account.
+For controller placement, see [deployment](docs/deployment.md). For optional
+standard OpenSandbox execution, see [Sandbox rollout](docs/sandbox-rollout.md).
+The independently installed [H-cluster practice](practices/h_cluster/README.md)
+contains Brainbox-specific deployment and restricted-network relay examples.
+
### Install
Use Python 3.10 or newer. Runtime dependencies, development dependencies, and the `harborrl` command are locked by [pyproject.toml](pyproject.toml) and [uv.lock](uv.lock). The CPU development install does not require CUDA, Docker, model weights, Slime, Megatron-LM, or SGLang.
@@ -128,7 +144,7 @@ The command style is a shorthand over `config.yaml` in the working directory (or
## Task Configuration
-Training is configured with one YAML file that has a fixed section and field set. Relative paths resolve against the YAML file, and repeated `--set section.field=value` overrides are parsed as YAML values and validated again. Start site-specific configurations from [examples/native/train_qwen_native.yaml](examples/native/train_qwen_native.yaml).
+Training is configured with one YAML file that has a fixed section and field set. Relative paths resolve against the YAML file, and repeated `--set section.field=value` overrides are parsed as YAML values and validated again. Start site-specific configurations from [examples/native/train_qwen_native.yaml](examples/native/train_qwen_native.yaml). Optional [train/inference consistency checks](docs/train-inference-consistency.md) compare frozen rollout scores with the pre-update trainer and probe synchronized inference engines.
The configuration has three broad parts: `execution` is fixed to `harbor_job`; `harbor`, `gateway`, and `deployment` describe rollout workers, service endpoints, and GPU topology; `tasks`, `harness`, `model`, `training`, `sampling`, and `output` describe the experiment and its outputs.
diff --git a/README_zh.md b/README_zh.md
index 18a33b25c..f75e744a9 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -25,6 +25,8 @@
## News
+- **2026-10-26** 新增[离线配置向导](docs/configuration-wizard.md)和[本地 Web 控制台](docs/web-console.md),支持配置、校验、dry-run、训练启动和日志查看。
+- **2026-10-26** 新增 [controller/GPU 部署配置](docs/deployment.md)、可选的 [OpenSandbox 执行能力](docs/sandbox-rollout.md),以及按需启用的[训练与推理一致性检查](docs/train-inference-consistency.md)。
- **10/2026** 📣📣📣 HarborRL released
## 项目介绍
@@ -43,6 +45,11 @@ HarborRL 支持 **Models × Algorithms × Tasks × Harnesses (MATH)** 的灵活
如果你希望让 AI agent 辅助完成本 repo 的部署和训练验证,请让它先阅读并严格遵循
[docs/agents/environment-check.md](docs/agents/environment-check.md)。
+离线选项目录、交互式配置和 answer-file 配置方式见[配置 CLI 指南](docs/configuration-wizard.md)。
+可以从 `harborrl options` 或 `harborrl init --help` 开始。
+运行 `harborrl web` 可打开本地浏览器控制台,完成配置、校验、dry-run、训练启动和日志查看;
+远程访问与生命周期说明见 [Web 控制台指南](docs/web-console.md)。
+
推荐的实际操作流程是把下面这段提示词交给 agent,并把尖括号内容替换成你的机器信息:
```text
@@ -73,6 +80,10 @@ doctor 和 launch_native --dry-run。
## 快速开始
+默认 CPU 安装不需要 Sandbox SDK 或集群账号。
+controller 与 GPU 的部署方式见[部署指南](docs/deployment.md),可选的标准 OpenSandbox 执行方式见[Sandbox rollout 指南](docs/sandbox-rollout.md)。
+独立安装的 [H-cluster 实践包](practices/h_cluster/README.md)提供 Brainbox 部署与受限网络中继示例。
+
### 安装
使用 Python 3.10 或更新版本。Python 依赖、开发依赖和 `harborrl` 命令由 [pyproject.toml](pyproject.toml) 与 [uv.lock](uv.lock) 锁定。CPU 开发环境不需要 CUDA、Docker、模型权重、Slime、Megatron-LM 或 SGLang。
diff --git a/docs/configuration-wizard.md b/docs/configuration-wizard.md
new file mode 100644
index 000000000..441a6138b
--- /dev/null
+++ b/docs/configuration-wizard.md
@@ -0,0 +1,140 @@
+# Configuration CLI
+
+The configuration tools run with the core package (Python 3.10+ and PyYAML).
+They enumerate implemented capabilities, check configuration offline, and create
+consistent training schema 2 / deployment schema 1 files. They do not install
+software, download tasks or models, connect over SSH, or allocate Sandbox/GPU jobs.
+
+## Browse and validate
+
+```bash
+harborrl options
+harborrl options deployments --json
+harborrl options fields --json
+harborrl validate site/config.yaml --deployment site/deployment.yaml --json
+harborrl validate site/config.yaml --deployment site/deployment.yaml --check-files
+```
+
+Catalog dimensions are `models`, `algorithms`, `tasks` (task providers),
+`harnesses`, `datasets`, `deployments`, `fields`, and `compat`. JSON keeps the
+`catalog_version` and `schema_versions` envelope when selecting a dimension.
+The catalog reflects registered implementations, not roadmap claims. Dataset
+`status` describes support; `available` describes local task files. A wheel does
+not include repository examples, and planned datasets are not ready to train.
+
+Validation defaults to schema, semantics, and cross-file consistency. It does
+not open referenced profiles or tasks until `--check-files` is supplied. Run file
+checks on the controller: its source/interpreter, profile, task catalog and
+Sandbox configuration belong there. Local GPU and runner paths are checked
+locally; SSH target paths are deferred. The shared root may be created by a later
+run and is not required to exist during configuration. Credentials are never read.
+
+Checks are `passed`, `failed`, or `deferred`. The report is `invalid` if a check
+fails, otherwise `valid_with_deferred_checks` while live checks remain. Exit codes
+are 0 for no failed checks, 1 for validation/creation failure, and 2 for CLI usage
+errors. `--json` writes one JSON object to stdout. Schema validators may report
+only their first error. Without `--deployment`, the existing CLI discovery of
+`./deployment.yaml` and embedded fallback is preserved and reported.
+
+GPU memory fit, model loading, live tokenizer fingerprints, package versions,
+connectivity and platform capacity are not established by offline validation.
+Sandbox image bindings are checked locally; trusted site adapters and credentials
+are checked later by runner preflight. Sandbox installation/profile timing
+compatibility is additionally checked by the existing training dry-run.
+
+## Prepare inputs
+
+1. Obtain Harbor-format tasks and run `harborrl hub inspect --tasks-dir /path/tasks
+ --dataset my-tasks --revision pinned-revision --run-dir /path/inspection`.
+ The resulting JSONL is an inspection report, not a training catalog. Build a
+ JSON list (or JSONL records with a `.jsonl` suffix) containing exactly `id`,
+ `revision`, `path`, `task_digest`, and `reward_profile` for each selected task.
+ Copy `path` and `task_digest` from a `NEEDS_PROBE` inspection record, assign a
+ unique stable `id`, retain the pinned revision, and explicitly set the verifier's
+ reward contract (for example `reward_profile: {key: reward}`). See
+ [the catalog example](../examples/native/catalog.jsonl). `INVALID` and
+ `UNSUPPORTED` inspection results cannot be made trainable by changing labels.
+ Do not replace computed digests with placeholder values.
+2. Prepare a pinned Claude Code profile. See
+ [the native examples](../examples/native) for profile structure. It must match
+ the agent and task requirements of your deployment.
+3. Stage checkpoint and reference directories on the GPU target. In its existing
+ training environment, load the actual local tokenizer with the same options as
+ the runtime and call `harborrl.rollout.runtime.tokenizer_fingerprint(tokenizer)`.
+ Its return values are `tokenizer_digest, template_digest`. This is an explicit
+ preparation step, not an operation performed by the wizard. Hashing a filename
+ or guessing an arbitrary 64-character value does not establish compatibility.
+4. Identify controller/GPU/runner Python executables, checkout paths, SSH aliases
+ and shared storage. Service origins must include their declared ports and be
+ reachable by the participating machines.
+5. For Sandbox, prepare the existing platform configuration and image bindings.
+ H-cluster users follow [the optional H practice](../practices/h_cluster/README.md)
+ and reference its site file. The core CLI never asks for H project/environment
+ identifiers or embeds private endpoints or credentials.
+
+## Generate
+
+```bash
+harborrl init --out-dir ./site-new
+harborrl init --answers my-answers.yaml --out-dir ./site-new
+```
+
+Interactive mode asks for deployment roles, existing inputs, model fingerprints,
+GPU layout and a few sampling settings, then previews both configurations and
+requires `yes` to publish. Entering no or EOF cancels. Non-terminal use requires
+`--answers`; missing required answers never fall back to prompting.
+
+Start from one of these complete answer templates and replace every `REPLACE_*`
+value and example path before running:
+
+- [embedded-local](../examples/options/embedded-local.answers.yaml): one machine,
+ local Harbor runner, colocated training/serving by default.
+- [service-ssh](../examples/options/service-ssh.answers.yaml): controller, SSH GPU
+ target and explicit SSH Docker workers.
+- [service-sandbox-local-runner](../examples/options/service-sandbox.answers.yaml):
+ controller and runner here, SSH GPU target, Sandbox task execution.
+
+Templates deliberately contain invalid digest placeholders. They cannot claim
+training readiness without your real inputs. Default one-GPU colocate is a
+configuration starting point, not a Qwen model memory-fit recommendation.
+
+Answers allow only `answers_version: 1`, `preset`, `training`, `deployment`, and
+optional `sandbox_config`. Overrides use the existing schema section/field names;
+unknown keys fail. No expressions or shell commands are evaluated. Gateway fields,
+shared run root, GPU budget and worker destinations are derived when only one
+side is provided. Contradictory explicit values fail. Supply matching origin URLs
+when changing ports. Paths in training overrides are relative to the **final
+output directory**, not the answers file. Deployment machine paths are absolute.
+
+The Sandbox preset can also describe remote runners through an answers file:
+set `training.harbor.runner_transport: ssh` and provide deployment rollout workers
+with `docker: false`. The builder derives training worker destinations and the
+first worker's runner Python. All configured interpreters still require preflight.
+
+`--probe-local` explicitly enables bounded local `nvidia-smi` UUID and current
+Python version queries. It also reads explicit SSH Host aliases (no wildcard,
+Match execution or Include expansion) and gives a parent-directory access hint.
+Missing tools and failed commands return `unknown`, not zero GPUs. Suggestions
+never silently change answers; no remote probe or write permission test occurs.
+
+The output directory must not exist, and its parent must exist. Publication uses
+an exclusive per-target lock, same-parent staging and a directory rename. Existing
+files are never overwritten. Failures clean up only this invocation's staging and
+lock. After an abrupt process termination, inspect any remaining `.NAME.init.lock`
+and staging directory before removing them; automatic stale-lock recovery is not
+attempted. The lock coordinates wizard processes, not arbitrary external writers.
+
+The bundle contains `config.yaml`, `deployment.yaml`, and `setup-manifest.json`.
+The manifest records schema versions, configuration digests, references and the
+offline validation scope, including deferred checks. It does not copy credentials.
+
+The CLI prints these next commands without executing them:
+
+```bash
+harborrl validate site-new/config.yaml --deployment site-new/deployment.yaml --check-files
+harborrl train site-new/config.yaml --deployment site-new/deployment.yaml --dry-run
+harborrl doctor site-new/config.yaml --deployment site-new/deployment.yaml
+```
+
+`doctor` performs live environment/connectivity checks. Actual training remains a
+separate action. Command examples use POSIX shell quoting.
diff --git a/docs/deployment.md b/docs/deployment.md
new file mode 100644
index 000000000..2f35a8501
--- /dev/null
+++ b/docs/deployment.md
@@ -0,0 +1,110 @@
+# Deployment configuration and the controller placement
+
+A `deployment.yaml`
+describes where the manager, GPU training target and rollout workers live and
+how they reach each other; the training YAML keeps every training-side field
+(learning rate, sampling, task catalog) and is unchanged.
+
+```bash
+harborrl train train.yaml --deployment deployment.yaml
+```
+
+Without `--deployment`, a `deployment.yaml` in the working directory is picked
+up; when no file exists the compatible embedded-local topology is used and the
+legacy same-machine behavior is preserved bit-for-bit (recorded in the plan as
+`deployment.source: embedded-local`).
+
+## What the schema covers (v1)
+
+- `manager` — the HarborRL controller node: `embedded` or `service` mode, bind
+ address, control/gateway ports and the URLs other machines use to reach them.
+ In `service` mode both URLs must be plain origins that match their ports and
+ must not be loopback.
+- `gpu_target` — the Slime training machine: `local` or `ssh` transport, the
+ checkout (`source_dir`), training interpreter (`train_python`), an optional
+ controlled `environment_file` (a JSON variable map, never a shell), and
+ `expected_gpus`.
+- `storage` — `shared_fs` mode with the shared `run_root` and writability
+ expectations per role.
+- `rollout` — `ssh_docker` workers: SSH destination, runner interpreter,
+ Docker requirement and task roots. The `embedded` provider keeps worker
+ discovery in the training config.
+- `connectivity` — timeouts and which control-loop hops are required.
+
+Secrets are rejected by the schema: no passwords, keys or API tokens. SSH
+destinations resolve through the invoking user's SSH config, agent or a
+restricted identity file. Paths are interpreted in target-machine semantics —
+the manager never assumes its own `sys.executable` works on the GPU or workers.
+See `examples/native/deployment-embedded.yaml` and
+`examples/native/deployment-service.yaml`.
+
+## Preflight graph
+
+`train` and `doctor` share one check graph, executed at the earliest launch
+stage. Each check records the role and machine it ran against, the outcome,
+timing, and a user-executable fix. Any failed check exits nonzero before a run
+directory or training process is created.
+
+Layer order: manager local state (run root, token file, ports) → GPU target
+(SSH reachability, paths, model paths, visible GPU budget) → rollout workers
+(SSH, runner Python >= 3.12, pinned Harbor version, Docker, task roots) →
+control-loop addresses (DNS resolution now, live health dials after components
+start). `--dry-run` parses both configs and prints the resolved plan without
+any remote probing or side effects.
+
+When the GPU target is remote, machine-local dependency probes (Ray, Torch,
+Slime imports, SGLang image, `nvidia-smi`) do not run on the manager: they are
+enforced fail-closed by `slime_train.sh` at GPU job startup. The manager CPU
+needs no GPU dependencies.
+
+## TrainingTarget
+
+`harborrl/deployment/training_target.py` launches one Slime job either as a
+local child (legacy path) or over SSH (service mode). The SSH target writes a
+generated `gpu-launcher.sh` into the shared run directory, starts it detached
+(`setsid`) on the GPU machine, records the launcher pid, polls the shared
+`exit-code` record, and cancels by signalling the remote process group
+(INT → TERM → KILL). It never resubmits or restarts a job.
+
+## RolloutService (manager mode)
+
+`harborrl/rollout/service.py` is the manager-side control plane: HTTP JSON
+with run-scoped tokens.
+
+- `GET /healthz`, `GET /readyz`
+- `POST /runs/{run_id}/register` — idempotent by manifest digest; the same id
+ with a different manifest is a 409.
+- `POST /runs/{run_id}/groups` — idempotent group submission. Same group id
+ and content returns the existing state; same id with different content is a
+ 409; submitting against a non-admitted policy version is a 409. On timeout
+ the response says `running` and points at the status endpoint — the client
+ must query, never resubmit.
+- `GET /runs/{run_id}/groups/{group_id}`, `POST .../cancel`
+- `POST /runs/{run_id}/policy/activate` — validates the whole serving pool is
+ healthy on the expected version, drains old admission, then opens the new
+ version and publishes `policy-pool.json` into the shared run directory.
+- `POST /runs/{run_id}/serving/announce` — the GPU job publishes its inference
+ endpoint; the manager never hand-configures it.
+- `POST /runs/{run_id}/drain`, `POST /runs/{run_id}/cancel`
+
+Bulky data (trajectory manifests, token ids, logprobs, loss masks, group
+export) is written to the shared run directory. HTTP responses carry paths
+plus SHA-256 digests; `GroupResult` also carries per-slot identity, finish
+reason, reward, trace seal status and verifier digest.
+
+The GPU-side adapter (`harborrl/backends/slime_v032/rollout_service_client.py`)
+recomputes every manifest digest, checks identities against the request,
+requires a complete group of at least two slots on the locked policy version,
+and only then converts trajectories into Slime samples. It is selected by
+`HARBORRL_ROLLOUT_SERVICE_URL` in the GPU job environment.
+
+## Operational limits
+
+Service mode requires shared storage visible at the configured paths on the
+controller, GPU target and runners. A successful dry run checks configuration;
+it does not establish network reachability, available capacity or training
+quality. Check connectivity with doctor and inspect run artifacts after launch.
+
+Choose worker and group concurrency for the target deployment. Instance
+readiness does not imply that an agent is running, and a completed checkpoint
+does not establish learning improvement.
diff --git a/docs/sandbox-rollout.md b/docs/sandbox-rollout.md
new file mode 100644
index 000000000..6fdc9d19a
--- /dev/null
+++ b/docs/sandbox-rollout.md
@@ -0,0 +1,72 @@
+# Sandbox rollout
+
+Sandbox is an optional execution environment under the common Controller,
+RolloutService, Runtime and Harbor Runner. Docker execution remains the default.
+The core does not import the SDK until an explicitly selected runner needs it.
+
+## Standard OpenSandbox
+
+Use Python 3.12 or newer for the runner and install `pip install -e '.[sandbox]'`.
+Start with `examples/native/sandbox.yaml`. Set the service origin (without a
+trailing /v1), a private credential-file reference and exact task image bindings.
+Standard credentials are opaque tokens, not a platform-specific AK:SK format.
+
+Select the environment in your training config:
+
+```yaml
+harbor:
+ python: /opt/harborrl/runner/bin/python
+ version: 0.23.0
+ runner_transport: local
+ workers: []
+ environment:
+ runtime: sandbox
+ config: /srv/harborrl/site/sandbox.yaml
+```
+
+For service placement, use `rollout.provider: sandbox` in the deployment file.
+A local runner requires an empty deployment worker list. SSH runners require
+explicit workers, the same shared task/evidence paths and the adapter installed
+on each runner. Runner placement is independent of the environment provider.
+The target and route must be validated before claiming that an SSH/Sandbox
+deployment works on a real platform.
+
+The standard adapter uses Harbor's OpenSandbox implementation and adds durable
+allocation tracking, bounded lifecycle operations and cleanup-debt records.
+Custom trusted adapters subclass ManagedOpenSandboxEnvironment and are selected
+through `adapter: module:Class`; arbitrary task input cannot select code.
+Provider settings belong in `options` and per-image binding `options`.
+They must not contain inline secrets. Core plan construction does not import
+the adapter, read credentials, connect to a platform or allocate instances.
+
+The optional `installation` block supports a pinned offline Claude binary:
+`mode: offline_binary`, `version`, `binary`, and its 64-character `sha256`.
+Without it, Harbor performs normal agent setup, which can need network access.
+
+## Concurrency and evidence
+
+`max_live_instances` bounds all active instances; each image binding has its
+own limit. Binding capacity is acquired before global capacity to avoid
+starvation. `startup_concurrency` separately bounds simultaneous SDK startup
+using process-shared locks. Shared-filesystem flock support is required when
+runners use the same lock directory across hosts.
+
+Groups use the same identity, policy barrier, verifier and consumption audit
+as Docker. Failed slots cancel their group siblings; submitted independent
+groups finish before a partial batch is rejected. Unconfirmed cleanup blocks
+new admission. Production export never accepts synthetic evidence.
+
+## Checks
+
+`python scripts/sandbox_sdk_contract.py` checks installed APIs and fake lifecycle
+cleanup without contacting a platform. `sandbox_probe` checks the selected
+adapter, SDK, credential-file permissions and optional binary locally.
+Its explicit `--network` mode is adapter-specific and reports skipped checks.
+
+`python scripts/sandbox_smoke.py CONFIG --output NEW_DIRECTORY --gateway URL`
+is a live allocation test and consumes platform resources. A successful CPU
+test or dependency probe does not establish live training acceptance.
+
+For Brainbox and developer-mediated networking, see the independently
+installable [H-cluster practice](../practices/h_cluster/README.md). H-specific
+configuration from the experimental branch must be migrated as described there.
diff --git a/docs/train-inference-consistency.md b/docs/train-inference-consistency.md
new file mode 100644
index 000000000..f4bd4d2d2
--- /dev/null
+++ b/docs/train-inference-consistency.md
@@ -0,0 +1,79 @@
+# Train / inference consistency
+
+Consistency checks compare log probabilities of the **same response tokens at
+the same prefix**, before an optimizer update. This measures backend drift;
+it does not measure model quality or full-distribution KL divergence.
+
+The three policies are the observed behavior `q`, frozen pre-update trainer
+`p0`, and current trainer `pθ`. Backend drift is `log p0 - log q`; optimization
+drift is `log pθ - log p0`. The Native loss remains unchanged. No TIS, clipping
+correction, or new training objective is introduced.
+
+## Configuration
+
+Native schema 2 accepts an optional `consistency` mapping. Its default mode is
+`off`. Existing configurations remain valid. `audit` records numerical violations;
+`enforce` rejects violations before
+training or serving admission. Invalid evidence always rejects, including in
+audit mode. Enforce requires an explicit `max_abs_delta`. Calibrate tolerances
+for the actual model, precision, parallel layout and kernels.
+
+```yaml
+consistency:
+ mode: audit
+ max_abs_delta: null
+ max_mean_abs_delta: null
+ bad_token_delta: null
+ max_bad_fraction: 0.0
+ worst_tokens: 8
+```
+
+Native overrides use the existing `--set consistency.mode=audit` interface.
+For enforce, configure a deployment-specific `max_abs_delta` in YAML before
+loading the configuration. The configuration is preserved in launch evidence.
+
+## Native Megatron / SGLang
+
+The enabled launcher runs `harborrl.consistency.train`, which uses the pinned
+Slime training loop and its supported actor factory. It never edits upstream
+Slime. The existing pre-update forward supplies actual packed-batch `p0` values.
+Immutable serving sidecars bind token IDs, float32-normalized behavior scores,
+response masks, policy version, tokenizer and template identity. Missing or
+mismatched sidecars reject the batch.
+
+Before each weight synchronization, the actor evaluates two fixed token
+fixtures using Megatron. Every synchronized SGLang engine then teacher-forces
+those exact sequences. Generation-time and post-probe versions must both match.
+Each engine must satisfy the numerical limits independently. Checks also cover
+the final sync, even when no subsequent rollout occurs. Probe records are reused
+only for the same pool, configuration and immutable reference. A fixed canary
+is a smoke check; actual rollout-batch checks provide broader coverage.
+
+Supported initial scope: dense models, synchronous full NCCL synchronization,
+PP=CP=1, with TP and DP. Temperature and top-p must be 1; top-k must be disabled.
+Critic, old-actor queues, MoE, release-train and routing replay are rejected.
+Transformed sampling would require a separate definition of behavior scores.
+
+`RUN_DIR/consistency` contains sync references, pool admissions, serving-turn
+sidecars and `batch-*.json` reports. References include a bounded sampled
+parameter fingerprint per rank; this is **not** a full weight checksum. Reports
+contain valid-token-weighted absolute/signed errors, log-ratio range, bad-token
+fraction, bounded worst positions and histogram percentile **upper bounds**.
+They do not copy raw prompt or response text. TP duplicate counts are excluded.
+Distributed validation collects rank errors before all ranks reject.
+
+## Validation and limitations
+
+Run `pytest -q tests/test_consistency.py` for the dependency-free checks. With
+Torch installed, run `pytest -q tests/test_consistency_torch.py` for two-rank
+failure propagation. These tests do not run a separate training loop.
+
+GPU acceptance must additionally exercise the locked Megatron / SGLang versions
+on the target hardware: initial sync, actual rollout tokens, optimizer update,
+final sync and checkpoint resume. Record dtype, attention backend, TP/DP, model
+revision and measured error distribution. CPU tests and mocked HTTP responses
+cannot certify SGLang input-logprob behavior or CUDA numerical tolerances.
+This implementation has not yet passed that live GPU acceptance gate.
+
+H-cluster relay topology remains a deployment practice. No H-cluster hostnames,
+private paths or machine-specific numerical thresholds belong in this module.
diff --git a/docs/web-console.md b/docs/web-console.md
new file mode 100644
index 000000000..3d9603ccc
--- /dev/null
+++ b/docs/web-console.md
@@ -0,0 +1,110 @@
+# Local web console
+
+```bash
+harborrl web --workspace ./harborrl-web
+```
+
+This opens a browser on an available loopback port. Keep the terminal running.
+`--port 8765` chooses a fixed port; `--no-browser` prints the URL without opening
+it. The workspace stores saved configuration bundles and execution records.
+One console process owns a workspace at a time. The web console uses the core
+Python dependencies; Node, a frontend build and CDN access are not required.
+
+## Configure and launch
+
+1. Select Local, SSH workers, or Sandbox. Fill controller/GPU/runner paths and
+ service origins. Source and interpreter paths refer to the selected machine.
+ The controller source directory and Python executable select the checkout and
+ environment used by the launched CLI.
+2. Provide existing checkpoint/reference directories, a pinned task catalog,
+ harness profile and real tokenizer/template fingerprints. The
+ [configuration CLI guide](configuration-wizard.md) describes preparing these
+ inputs. The console never downloads or generates model inputs.
+3. Choose GPU layout, tensor parallelism, rollout batches and sampling settings.
+ Allocation defaults are configuration values, not a model memory-fit estimate.
+4. Review the generated YAML and validation. **Check local files** additionally
+ checks references on the controller. Deferred GPU, connectivity, model and
+ Sandbox checks remain explicit. Save the bundle under a new configuration name.
+5. Run **Dry-run** to build the actual CLI launch plan. **Doctor** performs live
+ checks after confirmation. **Start training** requires a separate confirmation
+ and invokes the existing `harborrl train` flow, including its preflight.
+
+Configuration names are limited to letters, digits, underscores and hyphens.
+Saved configurations are not overwritten. Reopen one to launch it again, or edit
+and save under a new name. The advanced answers editor and YAML/JSON import use
+the existing `answers_version: 1` contract. Download an individual generated file
+or export answers from the editor. Advanced remote Sandbox runners can be
+specified through imported answers; the form preset defaults to a local runner.
+
+H-cluster deployments remain the optional
+[H practice](../practices/h_cluster/README.md). Prepare its Sandbox configuration
+separately and reference the path. H project/environment identifiers and relay
+settings are not part of the core web form.
+
+Windows supports configuration and dry-run. Live training/doctor controls require
+the console to run on a POSIX controller, using the same environment and checkout
+that would normally run the training CLI. Deployment readiness still depends on
+the project's supported Linux training environment.
+
+## Runs and shutdown
+
+Only one execution is active per workspace, including doctor and dry-run. This
+prevents duplicate launches within this console; it is not a cluster-wide GPU
+scheduler. Other CLI processes or different workspaces can still use the same
+resources, and operators must coordinate those allocations.
+
+Each job keeps immutable input files and a durable `job.json`/`console.log` under
+`WORKSPACE/jobs/JOB_ID`. Execution appends `web/JOB_ID` to the configured shared
+run root and updates both config files in its snapshot. The original saved bundle
+is unchanged. The existing runtime then creates its normal `training/...` run
+directory there. This isolates artifacts while retaining the configured shared
+filesystem. Run logs show the actual snapshot and output paths.
+
+The Runs view polls status and the last 64 KiB of output. **Controller** shows the
+CLI output and errors. **GPU training** reads the separate `train-stdout.log` when
+the service deployment creates one. Embedded training generally streams into
+the controller log. Full logs remain on disk; the browser download contains the
+currently displayed tail. This initial release does not aggregate WandB curves
+or claim remote/GPU progress from a quiet controller log.
+
+**Stop** sends an interrupt through the existing CLI lifecycle. Local training
+forwards it to the training process group; service mode uses the existing SSH
+target cancellation. `stopping` means cleanup is pending, not that resources are
+already free. Closing the browser does not stop a job. Ctrl+C or SIGTERM in the
+server terminal requests cleanup; shutdown waits up to 30 seconds. A timeout
+records an unknown state without force-killing arbitrary remote processes.
+
+After a crash or interrupted shutdown, previously running jobs appear as
+`unknown`. New launches are blocked. Inspect the target processes, shared run
+records and Sandbox cleanup evidence, then use **Confirm cleanup** to acknowledge
+that external cleanup is complete. This action records operator confirmation; it
+does not kill a stored PID or automatically relaunch a job. The server never
+signals a PID recovered from an old session.
+
+## Remote controller
+
+On the controller, from the intended checkout/environment:
+
+```bash
+python -m harborrl.cli web --workspace /srv/shared/web-console --port 8765 --no-browser
+```
+
+On the workstation, create a local SSH forward:
+
+```bash
+ssh -N -L 127.0.0.1:8765:127.0.0.1:8765 your-controller-alias
+```
+
+Open the URL printed by the controller in the workstation browser. If the local
+forward uses a different port, replace only the URL port. Leave the `#token=...`
+fragment intact. If either port is occupied, choose an unused port; omitting
+`--port` selects one automatically and prints it.
+
+The listener is deliberately fixed to `127.0.0.1`. A random per-session token
+protects API reads and writes; the browser moves the URL fragment into
+sessionStorage and removes it from the address bar. State-changing requests also
+require a same-origin JSON request. Responses use CSP, frame blocking and
+no-store headers, and assets are packaged locally. Keep the token private: this
+is a local single-user console with the controller user's filesystem and training
+privileges. It is not a public multi-user service or an Internet-facing gateway.
+Do not expose it through a public reverse proxy.
diff --git a/examples/native/catalog.jsonl b/examples/native/catalog.jsonl
new file mode 100644
index 000000000..eba16a31c
--- /dev/null
+++ b/examples/native/catalog.jsonl
@@ -0,0 +1 @@
+{"id": "hello_world", "path": "tasks/hello_world", "revision": "v0.1-example", "reward_profile": {"key": "reward", "offset": 0.0, "raw_range": [0.0, 1.0], "scale": 1.0}, "task_digest": "905f496d2852cca5b1e86e9de72395b41c291d2b8f6a80807e0ed24ca19541b3"}
diff --git a/examples/native/deployment-embedded.yaml b/examples/native/deployment-embedded.yaml
new file mode 100644
index 000000000..12c5cef81
--- /dev/null
+++ b/examples/native/deployment-embedded.yaml
@@ -0,0 +1,45 @@
+# Embedded (same-machine) deployment: identical to the legacy pipeline.
+# Use this file explicitly, or simply omit --deployment to get the same
+# embedded-local topology built from the training config.
+schema_version: 1
+
+manager:
+ id: embedded-local
+ mode: embedded
+ bind: 127.0.0.1
+ control_port: 49200
+ control_url: http://127.0.0.1:49200
+ gateway_port: 49184
+ gateway_url: http://127.0.0.1:49184
+ run_token_file: /run/secrets/harborrl/run-token
+ source_dir: /opt/harborrl/source
+ controller_python: /opt/harborrl/controller/bin/python
+
+gpu_target:
+ id: local
+ transport: local
+ ssh: null
+ source_dir: /opt/harborrl/source
+ train_python: /opt/harborrl/training/bin/python
+ environment_file: null
+ expected_gpus: 8
+
+storage:
+ mode: shared_fs
+ run_root: /mnt/shared/harborrl/runs
+ manager_writable: true
+ gpu_writable: true
+ worker_writable: true
+
+rollout:
+ # The embedded provider keeps worker discovery in the training config.
+ provider: embedded
+ workers: []
+
+connectivity:
+ command_timeout_s: 30
+ ssh_timeout_s: 15
+ http_timeout_s: 10
+ gpu_to_controller: true
+ worker_to_gateway: true
+ manager_to_gpu_inference: true
diff --git a/examples/native/deployment-service.yaml b/examples/native/deployment-service.yaml
new file mode 100644
index 000000000..b3584f5f9
--- /dev/null
+++ b/examples/native/deployment-service.yaml
@@ -0,0 +1,43 @@
+# Replace example hosts and paths; no site-specific addresses are required.
+schema_version: 1
+manager:
+ id: manager
+ mode: service
+ bind: 0.0.0.0
+ control_port: 49200
+ control_url: http://manager.example:49200
+ gateway_port: 49184
+ gateway_url: http://manager.example:49184
+ run_token_file: /srv/harborrl/site/run-token
+ source_dir: /srv/harborrl/repo
+ controller_python: /opt/harborrl/controller/bin/python
+gpu_target:
+ id: trainer
+ transport: ssh
+ ssh: {host: training-host}
+ source_dir: /srv/harborrl/repo
+ train_python: /opt/harborrl/training/bin/python
+ environment_file: /srv/harborrl/site/gpu-env.json
+ expected_gpus: 8
+storage:
+ mode: shared_fs
+ run_root: /srv/harborrl/runs
+ manager_writable: true
+ gpu_writable: true
+ worker_writable: true
+rollout:
+ provider: ssh_docker
+ workers:
+ - id: worker-a
+ ssh: {host: rollout-host}
+ source_dir: /srv/harborrl/repo
+ runner_python: /opt/harborrl/runner/bin/python
+ docker: true
+ task_roots: [/srv/harborrl/tasks]
+connectivity:
+ command_timeout_s: 30
+ ssh_timeout_s: 15
+ http_timeout_s: 10
+ gpu_to_controller: true
+ worker_to_gateway: true
+ manager_to_gpu_inference: true
diff --git a/examples/native/sandbox.yaml b/examples/native/sandbox.yaml
new file mode 100644
index 000000000..4ab3788a5
--- /dev/null
+++ b/examples/native/sandbox.yaml
@@ -0,0 +1,10 @@
+# Standard OpenSandbox; install harborrl[sandbox] in a Python 3.12+ runner.
+domain: https://sandbox.example
+credential_file: /run/secrets/harborrl/sandbox-key
+max_live_instances: 2
+startup_concurrency: 1
+bindings:
+ - image: registry.example/project/task:locked-version
+ max_live_instances: 2
+# Omit installation to use normal Harbor agent setup. Offline deployments
+# may supply mode, version, binary and sha256; see docs/sandbox-rollout.md.
diff --git a/examples/options/embedded-local.answers.yaml b/examples/options/embedded-local.answers.yaml
new file mode 100644
index 000000000..7a4bc5db1
--- /dev/null
+++ b/examples/options/embedded-local.answers.yaml
@@ -0,0 +1,30 @@
+# Replace every example path and REPLACE_* value. No model fit is implied.
+answers_version: 1
+preset: embedded-local
+training:
+ tasks:
+ catalog: /srv/harborrl/inputs/tasks.jsonl
+ harness:
+ profile: /srv/harborrl/inputs/claude-code.json
+ harbor:
+ python: /opt/harbor/bin/python
+ model:
+ checkpoint: /srv/models/qwen
+ reference: /srv/models/qwen-reference
+ args_file: qwen3-8B
+ gateway:
+ tokenizer_digest: REPLACE_WITH_ACTUAL_TOKENIZER_SHA256
+ template_digest: REPLACE_WITH_ACTUAL_TEMPLATE_SHA256
+ deployment:
+ layout: colocate
+ num_gpus: 1
+ sampling:
+ group_size: 2
+ groups_per_batch: 1
+deployment:
+ manager:
+ source_dir: /srv/harborrl/source
+ controller_python: /opt/controller/bin/python
+ gpu_target:
+ source_dir: /srv/harborrl/source
+ train_python: /opt/training/bin/python
diff --git a/examples/options/service-sandbox.answers.yaml b/examples/options/service-sandbox.answers.yaml
new file mode 100644
index 000000000..a55de6d2f
--- /dev/null
+++ b/examples/options/service-sandbox.answers.yaml
@@ -0,0 +1,34 @@
+# Prepare a standard or practice-specific Sandbox config separately.
+answers_version: 1
+preset: service-sandbox-local-runner
+sandbox_config: /srv/shared/site/sandbox.yaml
+training:
+ tasks:
+ catalog: /srv/shared/inputs/tasks.jsonl
+ harness:
+ profile: /srv/shared/inputs/claude-code.json
+ harbor:
+ python: /opt/sandbox-runner/bin/python
+ model:
+ checkpoint: /srv/models/qwen
+ reference: /srv/models/qwen-reference
+ args_file: qwen3-8B
+ gateway:
+ tokenizer_digest: REPLACE_WITH_ACTUAL_TOKENIZER_SHA256
+ template_digest: REPLACE_WITH_ACTUAL_TEMPLATE_SHA256
+ deployment:
+ layout: colocate
+ num_gpus: 1
+deployment:
+ manager:
+ source_dir: /srv/shared/harborrl
+ controller_python: /opt/controller/bin/python
+ control_url: http://controller.example:49200
+ gateway_url: http://controller.example:49184
+ gpu_target:
+ ssh:
+ host: gpu-training
+ source_dir: /srv/shared/harborrl
+ train_python: /opt/training/bin/python
+ storage:
+ run_root: /srv/shared/runs
diff --git a/examples/options/service-ssh.answers.yaml b/examples/options/service-ssh.answers.yaml
new file mode 100644
index 000000000..7a5851da4
--- /dev/null
+++ b/examples/options/service-ssh.answers.yaml
@@ -0,0 +1,44 @@
+# All paths refer to the named role's machine. Replace before use.
+answers_version: 1
+preset: service-ssh
+training:
+ tasks:
+ catalog: /srv/shared/inputs/tasks.jsonl
+ harness:
+ profile: /srv/shared/inputs/claude-code.json
+ model:
+ checkpoint: /srv/models/qwen
+ reference: /srv/models/qwen-reference
+ args_file: qwen3-8B
+ gateway:
+ tokenizer_digest: REPLACE_WITH_ACTUAL_TOKENIZER_SHA256
+ template_digest: REPLACE_WITH_ACTUAL_TEMPLATE_SHA256
+ deployment:
+ layout: split
+ num_gpus: 8
+ actor_gpus: 4
+ rollout_gpus: 4
+ actor_tensor_parallel_size: 4
+ rollout_gpus_per_engine: 4
+deployment:
+ manager:
+ source_dir: /srv/shared/harborrl
+ controller_python: /opt/controller/bin/python
+ control_url: http://controller.example:49200
+ gateway_url: http://controller.example:49184
+ gpu_target:
+ ssh:
+ host: gpu-training
+ source_dir: /srv/shared/harborrl
+ train_python: /opt/training/bin/python
+ storage:
+ run_root: /srv/shared/runs
+ rollout:
+ workers:
+ - id: runner-0
+ ssh:
+ host: rollout-worker
+ source_dir: /srv/shared/harborrl
+ runner_python: /opt/harbor/bin/python
+ docker: true
+ task_roots: [/srv/shared/tasks]
diff --git a/harborrl/algorithms/__init__.py b/harborrl/algorithms/__init__.py
new file mode 100644
index 000000000..61b32100b
--- /dev/null
+++ b/harborrl/algorithms/__init__.py
@@ -0,0 +1 @@
+"""Training algorithm adapters independent of backend hook plumbing."""
diff --git a/harborrl/algorithms/grpo.py b/harborrl/algorithms/grpo.py
new file mode 100644
index 000000000..34e98d7dc
--- /dev/null
+++ b/harborrl/algorithms/grpo.py
@@ -0,0 +1,18 @@
+"""GRPO semantics backed by the existing CPU-verified implementations."""
+from __future__ import annotations
+
+from harborrl.backends.slime_v032.advantage import compute_advantages_and_returns
+from harborrl.backends.slime_v032.loss import clipped_loss, loss_function
+
+
+class GRPOAlgorithmAdapter:
+ algorithm_id = "grpo"
+
+ def compute_advantages_and_returns(self, args, rollout_data):
+ return compute_advantages_and_returns(args, rollout_data)
+
+ def clipped_loss(self, *args, **kwargs):
+ return clipped_loss(*args, **kwargs)
+
+ def backend_loss_function(self, *args, **kwargs):
+ return loss_function(*args, **kwargs)
diff --git a/harborrl/backends/slime_v032/config.py b/harborrl/backends/slime_v032/config.py
new file mode 100644
index 000000000..cc52b864d
--- /dev/null
+++ b/harborrl/backends/slime_v032/config.py
@@ -0,0 +1,125 @@
+"""Schema-2 to official Slime launch mapping; no live probes."""
+import json
+import os
+import sys
+
+CONFIG_ENV = {
+ "HF_CKPT": ("model", "checkpoint"),
+ "REF_LOAD": ("model", "reference"),
+ "MODEL_ARGS_FILE": ("model", "args_file"),
+ "MODEL_TAG": ("model", "args_file"),
+ "NUM_ROLLOUT": ("training", "num_rollout"),
+ "SAVE_INTERVAL": ("training", "save_interval"),
+ "LR": ("training", "learning_rate"),
+ "ROLLOUT_BATCH_SIZE": ("sampling", "groups_per_batch"),
+ "N_SAMPLES": ("sampling", "group_size"),
+ "ROLLOUT_MAX_RESPONSE_LEN": ("sampling", "max_tokens"),
+ "ROLLOUT_MAX_CONTEXT_LEN": ("sampling", "max_context"),
+ "NUM_GPUS": ("deployment", "num_gpus"),
+ "ACTOR_GPUS": ("deployment", "actor_gpus"),
+ "ROLLOUT_GPUS": ("deployment", "rollout_gpus"),
+ "TP_SIZE": ("deployment", "actor_tensor_parallel_size"),
+ "ROLLOUT_NUM_GPUS_PER_ENGINE": ("deployment", "rollout_gpus_per_engine"),
+ "HARBORRL_GPU_LAYOUT": ("deployment", "layout"),
+ "HARBORRL_NATIVE_RUNNER_PYTHON": ("harbor", "python"),
+ "HARBORRL_NATIVE_PROFILE": ("harness", "profile"),
+ "HARBORRL_NATIVE_CATALOG": ("tasks", "catalog"),
+ "HARBORRL_NATIVE_GATEWAY_HOST": ("gateway", "host"),
+ "HARBORRL_NATIVE_GATEWAY_PORT": ("gateway", "port"),
+ "HARBORRL_NATIVE_GATEWAY_URL": ("gateway", "advertised_url"),
+ "HARBORRL_NATIVE_TOKENIZER_DIGEST": ("gateway", "tokenizer_digest"),
+ "HARBORRL_NATIVE_TEMPLATE_DIGEST": ("gateway", "template_digest"),
+ "HARBORRL_NATIVE_MAX_ATTEMPTS": ("sampling", "max_attempts"),
+ "RUNS_ROOT": ("output", "root"),
+}
+
+
+def launch_plan(config):
+ from harborrl.backends.slime_v032.versions import runtime_contract_id
+ from harborrl.config import ROOT
+ from harborrl.config.native import validate, validate_files
+ from harborrl.math_registry import select_math
+ from harborrl.tasks.catalog import catalog
+ validate(config, ROOT / "config.yaml")
+ validate_files(config)
+ selection = select_math(config)
+ entries = catalog(config)
+ runtime = config['harbor'].get('environment', {'runtime': 'docker'})
+ sandbox = None
+ if runtime['runtime'] == 'sandbox':
+ from harborrl.rollout.harbor_job.sandbox_config import (
+ load_sandbox,
+ task_binding,
+ )
+ from harborrl.trajectories.native import read_json
+ sandbox = load_sandbox(runtime['config'])
+ profile = read_json(config['harness']['profile'])
+ if sandbox['installation'] and sandbox['installation']['version'] != profile['cli_version']:
+ raise ValueError('sandbox installation version differs from harness profile')
+ budget = sum(profile[k] for k in ('agent_timeout_sec', 'setup_timeout_sec', 'verifier_timeout_sec'))
+ if sandbox['lifecycle_minutes'] * 60 <= budget + sandbox['create_timeout_s'] + 2 * sandbox['cleanup_timeout_s']:
+ raise ValueError('sandbox lifecycle must cover setup, agent, verifier and cleanup budgets')
+ for entry in entries:
+ task_binding(sandbox, entry['path'])
+ c = config
+ worker_overrides = os.environ.get('HARBORRL_NATIVE_TASK_WORKER_OVERRIDES', '{}')
+ try:
+ overrides = json.loads(worker_overrides)
+ valid_workers = set(c['harbor']['workers'])
+ if (not isinstance(overrides, dict) or any(not isinstance(key, str) or not isinstance(value, str) or value not in valid_workers
+ for key, value in overrides.items())):
+ raise ValueError
+ except (ValueError, json.JSONDecodeError) as exc:
+ raise ValueError('invalid HARBORRL_NATIVE_TASK_WORKER_OVERRIDES') from exc
+ environment = {
+ **{name: c[section][field] for name, (section, field) in CONFIG_ENV.items()},
+ 'DATASET': selection.task.id, 'ALGO': selection.algorithm.id,
+ 'HARNESS_OPTION': f"native-{selection.harness.id.replace('_', '-')}",
+ 'TRAIN_PYTHON': sys.executable,
+ 'HARBORRL_STRUCTURED_LAUNCH': '1', 'HARBORRL_VERIFY_POLICY_POOL': '1',
+ 'HARBORRL_SKIP_GLOBAL_CLEANUP': '1', 'SLIME_RAY_PLACEMENT_GPU_PROBE': '1',
+ 'PYTORCH_CUDA_ALLOC_CONF': ('max_split_size_mb:2048' if c['deployment']['layout'] == 'colocate'
+ else 'max_split_size_mb:2048,expandable_segments:True'),
+ 'HARBORRL_NATIVE_ROLLOUT': '1',
+ 'HARBORRL_NATIVE_SLIME_CONTRACT': runtime_contract_id(c['training']['backend_contract']),
+ 'HARBORRL_NATIVE_RUNNER_WORKERS': json.dumps(c['harbor']['workers']),
+ 'HARBORRL_NATIVE_TASK_WORKER_OVERRIDES': json.dumps(overrides, sort_keys=True),
+ 'HARBORRL_NATIVE_AUDITED_LOGPROBS': str(int(c['gateway']['audited_raw_logprobs'])),
+ 'HARBORRL_NATIVE_DRAIN_TIMEOUT': '30',
+ 'WANDB_ENABLE': '0',
+ 'HARBORRL_CONSISTENCY': json.dumps(c['consistency'], sort_keys=True),
+ 'HARBORRL_CONSISTENCY_MODE': c['consistency']['mode'],
+ }
+ environment['HARBORRL_NATIVE_RUNNER_TRANSPORT'] = c['harbor'].get('runner_transport', 'ssh')
+ if sandbox is not None:
+ environment['HARBORRL_NATIVE_SANDBOX'] = json.dumps(sandbox, sort_keys=True)
+ return {'config': config, 'math': selection.asdict(),
+ 'command': [sys.executable, '-m', 'harborrl.platform.native_train'],
+ 'environment': {key: str(value) for key, value in environment.items()},
+ 'training_command': ['bash', str(ROOT / 'harborrl/platform/slime_train.sh')],
+ 'required_services': ['GPU Ray / SGLang / Messages Gateway',
+ 'Sandbox / Harbor runner' if sandbox else 'external CPU Harbor Docker workers'],
+ 'launcher': f"native {selection.harness.display_name} → Messages Gateway → "
+ f"{selection.task.display_name} verifier → "
+ f"{selection.algorithm.display_name}",
+ 'layout': {'mode': config['deployment']['layout'], 'offload': config['deployment']['layout']=='colocate'}}
+
+
+def field_usage():
+ """Account for every schema-2 field, including non-environment consumers."""
+ usage = {}
+ from harborrl.consistency.config import DEFAULTS
+ usage.update({f'consistency.{key}': ['environment:HARBORRL_CONSISTENCY'] for key in DEFAULTS})
+ for name, (section, field) in CONFIG_ENV.items():
+ usage.setdefault(f"{section}.{field}", []).append(f"environment:{name}")
+ usage.update({
+ "execution.backend": ["schema:harbor_job"],
+ "harness.name": ["schema:claude_code", "runner:Claude Code"],
+ "harbor.version": ["preflight:worker runtime version"],
+ "training.backend_contract": ["environment:HARBORRL_NATIVE_SLIME_CONTRACT", "preflight:backend hooks"],
+ "harbor.workers": ["environment:HARBORRL_NATIVE_RUNNER_WORKERS", "preflight:worker probe"],
+ "harbor.runner_transport": ["environment:HARBORRL_NATIVE_RUNNER_TRANSPORT"],
+ "harbor.environment": ["environment:HARBORRL_NATIVE_SANDBOX", "runner:environment"],
+ "gateway.audited_raw_logprobs": ["environment:HARBORRL_NATIVE_AUDITED_LOGPROBS"],
+ })
+ return usage
diff --git a/harborrl/backends/slime_v032/converter.py b/harborrl/backends/slime_v032/converter.py
index 6098332b3..075a121ae 100644
--- a/harborrl/backends/slime_v032/converter.py
+++ b/harborrl/backends/slime_v032/converter.py
@@ -131,6 +131,8 @@ def convert_samples_to_train_data(args, samples):
data["loss_masks"].append(response_mask)
data["rollout_log_probs"].append(list(span.old_logprobs))
data["rollout_mask_sums"].append(unit.token_count)
+ from harborrl.consistency.slime import record_turn
+ record_turn(ir["turns"][turn_index], list(span.tokens), list(span.old_logprobs), response_mask)
for group in batch.groups:
weight_sum = math.fsum(
1.0 / (group.group_size * unit.token_count)
diff --git a/harborrl/backends/slime_v032/launcher.py b/harborrl/backends/slime_v032/launcher.py
index c0f5c9d0d..3450782d1 100644
--- a/harborrl/backends/slime_v032/launcher.py
+++ b/harborrl/backends/slime_v032/launcher.py
@@ -4,40 +4,37 @@
import sys
from harborrl.backends.slime_v032.versions import BACKEND_CONTRACT
+from harborrl.math_registry import MATH_REGISTRY
-HOOK_PATHS = {
- "rollout": "harborrl.backends.slime_v032.rollout.generate_rollout",
- "converter": "harborrl.backends.slime_v032.converter.convert_samples_to_train_data",
- "advantage": "harborrl.backends.slime_v032.advantage.compute_advantages_and_returns",
- "loss": "harborrl.backends.slime_v032.loss.loss_function",
- "postprocess": "harborrl.backends.slime_v032.postprocess.rollout_data_postprocess",
-}
-
-def hook_arguments(contract=BACKEND_CONTRACT):
+def _hooks(contract=BACKEND_CONTRACT):
if str(contract) != BACKEND_CONTRACT:
raise ValueError(f"unsupported backend contract: {contract!r}")
+ return MATH_REGISTRY.algorithm_for_contract(str(contract)).backend_hooks
+
+
+def hook_arguments(contract=BACKEND_CONTRACT):
+ hooks = _hooks(contract)
return [
- "--rollout-function-path", HOOK_PATHS["rollout"],
- "--custom-convert-samples-to-train-data-path", HOOK_PATHS["converter"],
- "--custom-advantage-function-path", HOOK_PATHS["advantage"],
+ "--rollout-function-path", hooks["rollout"],
+ "--custom-convert-samples-to-train-data-path", hooks["converter"],
+ "--custom-advantage-function-path", hooks["advantage"],
"--loss-type", "custom_loss",
- "--custom-loss-function-path", HOOK_PATHS["loss"],
- "--rollout-data-postprocess-path", HOOK_PATHS["postprocess"],
+ "--custom-loss-function-path", hooks["loss"],
+ "--rollout-data-postprocess-path", hooks["postprocess"],
]
def validate_hook_assignment(assignment, *, contract=BACKEND_CONTRACT):
- if str(contract) != BACKEND_CONTRACT:
- raise ValueError(f"unsupported backend contract: {contract!r}")
- observed = {key: value for key, value in assignment.items() if key in HOOK_PATHS}
- if observed != HOOK_PATHS:
+ hooks = _hooks(contract)
+ observed = {key: value for key, value in assignment.items() if key in hooks}
+ if observed != hooks:
raise ValueError(f"Slime hook assignment must exactly match {BACKEND_CONTRACT}")
return observed
def hook_modules():
- return {name: path.rsplit(".", 1)[0] for name, path in HOOK_PATHS.items()}
+ return {name: path.rsplit(".", 1)[0] for name, path in _hooks().items()}
def dry_run_command(entrypoint, base_args=(), *, contract=BACKEND_CONTRACT, python=sys.executable):
diff --git a/harborrl/backends/slime_v032/loss.py b/harborrl/backends/slime_v032/loss.py
index 72f386168..502c146eb 100644
--- a/harborrl/backends/slime_v032/loss.py
+++ b/harborrl/backends/slime_v032/loss.py
@@ -104,5 +104,12 @@ def loss_function(args, batch, logits, unused_reducer):
"native_token_count": loss_masks.sum().detach(),
"native_group_count": weight_sum.detach().round(),
}
+ from harborrl.consistency.config import environment
+ if environment()["mode"] != "off":
+ p0 = torch.cat(batch["log_probs"]).detach()
+ current = torch.cat(output["log_probs"]).detach()
+ metrics["consistency_backend_abs_sum"] = ((p0 - old_logprobs).abs() * loss_masks).sum()
+ metrics["consistency_update_abs_sum"] = ((current - p0).abs() * loss_masks).sum()
+ metrics["consistency_valid_tokens"] = loss_masks.sum().detach()
return loss * LOSS_SCALE_CORRECTION, metrics
diff --git a/harborrl/backends/slime_v032/postprocess.py b/harborrl/backends/slime_v032/postprocess.py
index d778412bc..7d4c89bb3 100644
--- a/harborrl/backends/slime_v032/postprocess.py
+++ b/harborrl/backends/slime_v032/postprocess.py
@@ -3,8 +3,6 @@
import math
-from harborrl.trajectories.native import finite
-
REQUIRED_FIELDS = (
"tokens",
"response_lengths",
@@ -34,7 +32,7 @@ def _integer_scalar(value):
return int(number) if math.isfinite(number) and number.is_integer() else None
-def rollout_data_postprocess(args, rollout_id, rollout_data):
+def _validate_rollout_data(args, rollout_id, rollout_data):
"""Slime ``--rollout-data-postprocess-path`` hook; fail closed on drift."""
missing = [field for field in REQUIRED_FIELDS if field not in rollout_data]
if missing:
@@ -51,8 +49,11 @@ def rollout_data_postprocess(args, rollout_id, rollout_data):
group_size = int(getattr(args, "n_samples_per_prompt", 0))
if group_size < 2:
raise ValueError("native training requires a complete group")
- local_weight_sum = 0.0
+ group_weights = {}
for index in range(count):
+ group_id = _integer_scalar(rollout_data["rollout_ids"][index])
+ if group_id is None or group_id < 0:
+ raise ValueError("invalid native rollout id")
response_length = _integer_scalar(rollout_data["response_lengths"][index])
mask = rollout_data["loss_masks"][index]
logprobs = rollout_data["rollout_log_probs"][index]
@@ -71,8 +72,22 @@ def rollout_data_postprocess(args, rollout_id, rollout_data):
raise ValueError("invalid native rollout mask sum")
if truncated is None or truncated not in (0, 1):
raise ValueError("invalid native truncated flag")
- local_weight_sum += sum(
- 1.0 / (group_size * mask_sum) for flag in mask if flag
+ group_weights.setdefault(group_id, []).append(
+ sum(1 for flag in mask if flag) / (group_size * mask_sum)
)
- if not finite(local_weight_sum) or local_weight_sum > 1.0 + 1e-9:
- raise ValueError(f"native local weights exceed the one-per-group budget: {local_weight_sum}")
+ # Actor shards may contain partial groups; each group has its own unit budget.
+ for group_id, weights in group_weights.items():
+ local_weight_sum = math.fsum(weights)
+ if not math.isfinite(local_weight_sum) or local_weight_sum > 1.0 + 1e-9:
+ raise ValueError(
+ f"native local weights exceed the one-per-group budget for rollout id "
+ f"{group_id}: {local_weight_sum}"
+ )
+
+
+def rollout_data_postprocess(args, rollout_id, rollout_data):
+ from harborrl.consistency.config import environment
+ if environment()["mode"] == "off":
+ return _validate_rollout_data(args, rollout_id, rollout_data)
+ from harborrl.consistency.slime import check_training_batch
+ return check_training_batch(args, rollout_id, rollout_data, _validate_rollout_data)
diff --git a/harborrl/backends/slime_v032/rollout.py b/harborrl/backends/slime_v032/rollout.py
index c232921c2..44c36e2c8 100644
--- a/harborrl/backends/slime_v032/rollout.py
+++ b/harborrl/backends/slime_v032/rollout.py
@@ -1,11 +1,28 @@
-"""Slime v0.3.2 rollout function hook for complete Native Harbor groups."""
+"""Slime v0.3.2 rollout function hook for complete Native Harbor groups.
+
+This module is the training-side adapter: it maps Slime args and Sample
+objects onto the framework-free rollout core. When the manager runs the
+RolloutService (P3), groups are requested over HTTP instead of executing in
+this process; conversion to Slime samples is identical either way.
+"""
from __future__ import annotations
import asyncio
import math
+import os
+from pathlib import Path
from harborrl.backends.slime_v032.versions import lock_policy_version
from harborrl.export.native import export_training_batch
+from harborrl.rollout.runtime import (
+ RolloutRuntimeConfig,
+ close_runtime,
+ current_policy_version,
+ digest,
+ run_group,
+ runtime,
+ task_entry,
+)
def sampling_params(args):
@@ -21,6 +38,30 @@ def sampling_params(args):
}
+def runtime_config_from_args(args):
+ """Map Slime runtime args onto the extracted rollout core config."""
+ endpoint = f"http://{args.sglang_router_ip}:{args.sglang_router_port}"
+ engine_count = int(args.rollout_num_gpus) // int(args.rollout_num_gpus_per_engine)
+ limit = max(1, int(args.sglang_server_concurrency) * engine_count)
+ return RolloutRuntimeConfig.from_environment(
+ os.environ, endpoint=endpoint, concurrency_limit=limit,
+ group_size=int(args.n_samples_per_prompt))
+
+
+def slime_tokenizer_loader(args):
+ """The single Slime dependency at the rollout boundary: the tokenizer."""
+ from slime.utils.processing_utils import load_tokenizer
+
+ return lambda: load_tokenizer(args.hf_checkpoint, trust_remote_code=True)
+
+
+def group_index_of(group):
+ indexes = {getattr(sample, "group_index", None) for sample in group}
+ if len(indexes) != 1 or type(next(iter(indexes), None)) is not int:
+ raise ValueError("Slime must provide one group identity before native generation")
+ return indexes.pop()
+
+
def assert_locked_policy_versions(ir_groups, policy_version):
"""Fail closed if any generation escaped the locked weight version."""
for group in ir_groups:
@@ -32,11 +73,128 @@ def assert_locked_policy_versions(ir_groups, policy_version):
raise ValueError("native rollout observed a stale policy version")
+async def generate_group(args, group, sampling_params_, rollout_id, evaluation=False):
+ """Run one complete native group; evaluation is intentionally unsupported."""
+ from harborrl.math_registry import NATIVE_TASK_PROVIDER
+
+ if evaluation:
+ raise ValueError("native training rollout cannot be reused as evaluation")
+ if len(group) != args.n_samples_per_prompt:
+ raise ValueError("native rollout group is incomplete before launch")
+ entries = [task_entry(sample) for sample in group]
+ if any(entry != entries[0] for entry in entries[1:]):
+ raise ValueError("native rollout group mixes task locks")
+ entry = entries[0]
+ report = NATIVE_TASK_PROVIDER.adapter.inspect(
+ entry["path"], expected_digest=entry["task_digest"]
+ )
+ if report["status"] != "NEEDS_PROBE":
+ raise ValueError(f"native task preflight failed: {report['reasons']}")
+ stop = list(sampling_params_.get("stop") or [])
+ sampling_digest = digest({
+ "temperature": sampling_params_.get("temperature"),
+ "top_p": sampling_params_.get("top_p"),
+ "top_k": sampling_params_.get("top_k"),
+ "max_new_tokens": sampling_params_.get("max_new_tokens"),
+ "stop": stop,
+ })
+ config = runtime_config_from_args(args)
+ runtime_ = await runtime(config, tokenizer_loader=slime_tokenizer_loader(args))
+ policy_version = current_policy_version(runtime_)
+ trajectories = await run_group(
+ config, runtime_, entry=entry, group_index=group_index_of(group),
+ rollout_id=rollout_id, sampling_digest=sampling_digest,
+ policy_version=policy_version,
+ )
+ return bind_group_samples(group, trajectories)
+
+
+def bind_group_samples(group, trajectories):
+ """Attach trajectory evidence to Slime samples; order defines the slots."""
+ output = []
+ for sample, ir in zip(group, trajectories):
+ sample.metadata["native_ir"] = ir
+ sample.response = "native Harbor trial"
+ sample.reward = {"score": ir["evaluation"]["training_reward"]}
+ sample.status = sample.Status.COMPLETED
+ try:
+ sample.policy_version = int(ir["identity"]["policy_version"])
+ except ValueError as exc:
+ raise ValueError("native policy version must be an integer in Slime") from exc
+ output.append(sample)
+ return output
+
+
+async def run_service_groups(args, groups, rollout_id):
+ """Request complete groups from the manager-side RolloutService (P3)."""
+ from harborrl.backends.slime_v032.rollout_service_client import (
+ RolloutServiceClient,
+ fetch_pool_states,
+ load_group_result,
+ service_group_request,
+ )
+
+ token_file = os.environ.get('HARBORRL_ROLLOUT_SERVICE_TOKEN_FILE')
+ token = Path(token_file).read_text().strip() if token_file else os.environ.get('HARBORRL_ROLLOUT_SERVICE_TOKEN', '')
+ client = RolloutServiceClient(
+ os.environ["HARBORRL_ROLLOUT_SERVICE_URL"], os.environ["RUN_ID"],
+ token)
+ router = f"http://{args.sglang_router_ip}:{args.sglang_router_port}"
+ # Policy lifecycle (§7): the GPU job publishes its serving endpoint, then
+ # the manager admits the version the complete pool actually serves. The
+ # manager never opens a version some engine has not reached yet.
+ await asyncio.to_thread(client.announce_serving, router)
+ states = await asyncio.to_thread(fetch_pool_states, router)
+ from harborrl.consistency.config import environment
+ from harborrl.consistency.sglang import admit_pool
+ if environment()['mode'] != 'off':
+ await asyncio.to_thread(admit_pool, states, os.environ["RUN_DIR"])
+ await asyncio.to_thread(client.activate_policy, states, states[0]["weight_version"])
+ # The manager's activation publishes the locked policy-pool record into
+ # the shared run directory; lock the same version before requesting.
+ policy_version = lock_policy_version(args)
+ params = sampling_params(args)
+ async def request_group(group):
+ request = service_group_request(group, params, rollout_id, policy_version)
+ payload = await asyncio.to_thread(client.submit_group, request)
+ # The HTTP layer returns a status summary; completed work carries the
+ # GroupResult itself.
+ result = payload.get("result") if isinstance(payload, dict) else None
+ if not isinstance(result, dict):
+ from harborrl.backends.slime_v032.rollout_service_client import (
+ ServiceRolloutError,
+ )
+ raise ServiceRolloutError(
+ f"group {request['group_id']} is {payload.get('status')!r}: "
+ f"{payload.get('error', '')}")
+ irs = load_group_result(client.run_id, request, result, allow_synthetic=False)
+ bind_group_samples(group, irs)
+ return irs
+
+ pending = [asyncio.create_task(request_group(group)) for group in groups]
+ try:
+ results = await asyncio.gather(*pending, return_exceptions=True)
+ # Retain independent groups' evidence, but never export a partial batch.
+ for result in results:
+ if isinstance(result, BaseException):
+ raise result
+ return results
+ except BaseException:
+ # HTTP workers keep running until the manager acknowledges cancellation.
+ try:
+ await asyncio.to_thread(client.cancel_run)
+ finally:
+ await asyncio.gather(*pending, return_exceptions=True)
+ raise
+
+
async def run_native_groups(args, groups, policy_version, rollout_id):
"""Run every group concurrently and return only complete IR evidence."""
- from harborrl.rollout.native_generate import close_runtime, generate_group
-
params = sampling_params(args)
+ if os.environ.get("HARBORRL_ROLLOUT_SERVICE_URL"):
+ ir_groups = await run_service_groups(args, groups, rollout_id)
+ assert_locked_policy_versions(ir_groups, policy_version)
+ return ir_groups
try:
results = await asyncio.gather(*(
generate_group(args, group, params, rollout_id=rollout_id) for group in groups
diff --git a/harborrl/backends/slime_v032/rollout_service_client.py b/harborrl/backends/slime_v032/rollout_service_client.py
new file mode 100644
index 000000000..4fc73e492
--- /dev/null
+++ b/harborrl/backends/slime_v032/rollout_service_client.py
@@ -0,0 +1,210 @@
+"""SlimeAdapter HTTP client (P3): complete groups from the RolloutService.
+
+The GPU job never trusts a GroupResult: every manifest digest is recomputed,
+identities are checked against the request, the group must be complete with
+at least two RL_READY slots, and the policy version must be the locked one.
+After a transport timeout the client queries status; it never resubmits.
+"""
+from __future__ import annotations
+
+import json
+import time
+from pathlib import Path
+from urllib import error, parse, request
+
+from harborrl.rollout.runtime import task_entry
+from harborrl.trajectories.native import digest, read_json, require_ready
+
+SERVICE_SCHEMA = "harborrl-rollout-service-v1"
+
+
+def fetch_pool_states(endpoint, *, timeout=120, opener=None):
+ """Read the serving pool states directly from the SGLang router.
+
+ Mirrors SGLangBackend.pool_states without a tokenizer: the GPU job calls
+ this before asking the manager to admit the version it currently serves.
+ """
+ import json as json_module
+ from urllib import request as url_request
+
+ opener = opener or url_request.build_opener(url_request.ProxyHandler({}))
+ endpoint = endpoint.rstrip("/")
+
+ def get(url):
+ with opener.open(url_request.Request(url), timeout=timeout) as response:
+ return json_module.load(response)
+
+ workers = get(endpoint + "/workers").get("workers")
+ if not isinstance(workers, list) or not workers:
+ raise ServiceRolloutError("serving router returned no workers")
+ states = []
+ for worker in workers:
+ engine = worker.get("url") if isinstance(worker, dict) else None
+ if not isinstance(engine, str) or not engine.strip():
+ raise ServiceRolloutError("serving router returned an invalid worker URL")
+ engine = engine.rstrip("/")
+ version = get(engine + "/get_weight_version").get("weight_version")
+ if not isinstance(version, str) or not version.strip():
+ raise ServiceRolloutError("missing serving weight version")
+ states.append({"endpoint": engine, "healthy": True,
+ "weight_version": version})
+ versions = {state["weight_version"] for state in states}
+ if len(versions) != 1:
+ raise ServiceRolloutError(
+ f"serving pool is mid-update; retry after all engines agree: {sorted(versions)}")
+ return states
+
+
+class ServiceRolloutError(RuntimeError):
+ def __init__(self, message, *, status=None, code=None):
+ super().__init__(message)
+ self.status, self.code = status, code
+
+
+def service_group_request(group, sampling_params, rollout_id, policy_version):
+ """Build one GroupRequest from a Slime sample group."""
+ entries = [task_entry(sample) for sample in group]
+ if any(entry != entries[0] for entry in entries[1:]):
+ raise ValueError("native rollout group mixes task locks")
+ indexes = {getattr(sample, "group_index", None) for sample in group}
+ if len(indexes) != 1 or type(next(iter(indexes), None)) is not int:
+ raise ValueError("Slime must provide one group identity before native generation")
+ index = indexes.pop()
+ return {
+ "group_id": f"group-{index}",
+ "rollout_id": int(rollout_id),
+ "group_index": index,
+ "policy_version": str(policy_version),
+ "task": entries[0],
+ "sampling": {key: sampling_params.get(key) for key in
+ ("temperature", "top_p", "top_k", "max_new_tokens", "stop")},
+ "slots": [{"slot_id": str(slot)} for slot in range(len(group))],
+ }
+
+
+class RolloutServiceClient:
+ """Synchronous urllib client; safe to call from asyncio.to_thread."""
+
+ def __init__(self, base_url, run_id, token, *, timeout=120, poll_interval=1.0,
+ opener=None):
+ self.base_url = base_url.rstrip("/")
+ self.run_id = run_id
+ self.token = token
+ self.timeout = timeout
+ self.poll_interval = poll_interval
+ self.opener = opener or request.build_opener(request.ProxyHandler({}))
+
+ def _request(self, method, path, body=None):
+ url = f"{self.base_url}{path}"
+ data = json.dumps(body).encode() if body is not None else None
+ headers = {"Content-Type": "application/json"}
+ if self.token:
+ headers["Authorization"] = f"Bearer {self.token}"
+ req = request.Request(url, data=data, headers=headers, method=method)
+ try:
+ with self.opener.open(req, timeout=self.timeout) as response:
+ return json.load(response)
+ except error.HTTPError as exc:
+ try:
+ payload = json.load(exc)
+ except (ValueError, OSError):
+ payload = {"error": {"type": "transport", "message": str(exc)}}
+ error_payload = payload.get("error", payload)
+ raise ServiceRolloutError(
+ f"{method} {path}: {error_payload.get('type')}: "
+ f"{error_payload.get('message')}",
+ status=exc.code, code=error_payload.get("type")) from exc
+ except (error.URLError, OSError, ValueError) as exc:
+ raise ServiceRolloutError(f"{method} {path}: {exc}",
+ code="transport") from exc
+
+ def health(self):
+ return self._request("GET", "/healthz")
+
+ def register(self, manifest):
+ return self._request("POST", f"/runs/{parse.quote(self.run_id)}/register",
+ {"manifest": manifest})
+
+ def submit_group(self, group_request, *, max_attempts=None):
+ """Submit idempotently; on timeout poll status, never regenerate."""
+ path = f"/runs/{parse.quote(self.run_id)}/groups"
+ try:
+ status = self._request('POST', path, group_request)
+ except ServiceRolloutError as exc:
+ if exc.code != 'transport':
+ raise
+ status = self.group_status(group_request['group_id'])
+ polls = 0
+ while status['status'] in ('queued', 'running'):
+ if max_attempts is not None and polls >= max_attempts:
+ raise ServiceRolloutError('group polling deadline reached', code='transport')
+ polls += 1
+ time.sleep(self.poll_interval)
+ status = self.group_status(group_request["group_id"])
+ return status
+
+ def group_status(self, group_id):
+ return self._request(
+ "GET", f"/runs/{parse.quote(self.run_id)}/groups/{parse.quote(group_id)}")
+
+ def cancel_group(self, group_id):
+ return self._request(
+ "POST",
+ f"/runs/{parse.quote(self.run_id)}/groups/{parse.quote(group_id)}/cancel")
+
+ def activate_policy(self, states, expected):
+ return self._request(
+ "POST", f"/runs/{parse.quote(self.run_id)}/policy/activate",
+ {"states": states, "expected": str(expected)})
+
+ def announce_serving(self, router_url):
+ return self._request(
+ "POST", f"/runs/{parse.quote(self.run_id)}/serving/announce",
+ {"router_url": router_url})
+
+ def drain(self):
+ return self._request("POST", f"/runs/{parse.quote(self.run_id)}/drain")
+
+ def cancel_run(self):
+ return self._request("POST", f"/runs/{parse.quote(self.run_id)}/cancel")
+
+
+def load_group_result(run_id, group_request, result, *, allow_synthetic=False):
+ """Validate a GroupResult and load its trajectory manifests in slot order."""
+ if not isinstance(result, dict):
+ raise ServiceRolloutError("group result must be a mapping")
+ if result.get("status") != "complete":
+ raise ServiceRolloutError(
+ f"group is {result.get('status')!r}, not complete: {result.get('error', '')}")
+ if result.get("run_id") != run_id or result.get("group_id") != group_request["group_id"]:
+ raise ServiceRolloutError("group result identity does not match the request")
+ if result.get("request_digest") != digest(group_request):
+ raise ServiceRolloutError("group result digest does not match the request")
+ slots = result.get("slots")
+ expected = [slot["slot_id"] for slot in group_request["slots"]]
+ if (not isinstance(slots, list) or len(slots) != len(expected) or len(slots) < 2
+ or [slot.get("slot_id") for slot in slots] != expected):
+ raise ServiceRolloutError("group result is incomplete or reordered")
+ trajectories = []
+ for descriptor, slot in zip(group_request["slots"], slots):
+ path = Path(slot["manifest_path"])
+ try:
+ payload = read_json(path)
+ except (OSError, ValueError) as exc:
+ raise ServiceRolloutError(f"cannot read slot manifest: {exc}") from exc
+ if digest(payload) != slot.get("manifest_sha256"):
+ raise ServiceRolloutError(f"slot {descriptor['slot_id']} manifest digest mismatch")
+ identity = payload.get("identity", {})
+ if (identity.get("policy_version") != group_request["policy_version"]
+ or identity.get("task_digest") != group_request["task"]["task_digest"]
+ or identity.get("run_id") != run_id
+ or identity.get("group_id") != group_request["group_id"]
+ or identity.get("slot_id") != descriptor["slot_id"]):
+ raise ServiceRolloutError(
+ f"slot {descriptor['slot_id']} identity differs from the request")
+ try:
+ require_ready(payload, allow_synthetic=allow_synthetic)
+ except ValueError as exc:
+ raise ServiceRolloutError(f"slot {descriptor['slot_id']}: {exc}") from exc
+ trajectories.append(payload)
+ return trajectories
diff --git a/harborrl/cli.py b/harborrl/cli.py
index 55530e0a1..0ea67a8a0 100644
--- a/harborrl/cli.py
+++ b/harborrl/cli.py
@@ -4,18 +4,35 @@
import argparse
import json
import sys
-
-ALGORITHM_CONTRACTS = {"grpo": "slime-v0.3.2-native-v1"}
+from pathlib import Path
def main(argv=None):
argv = list(sys.argv[1:] if argv is None else argv)
+ if argv and argv[0] == "web":
+ from harborrl.webui.cli import main as web_main
+
+ return web_main(argv[1:])
+ if argv and argv[0] in {"options", "validate", "init"}:
+ from harborrl.options.cli import main as options_main
+
+ return options_main(argv)
+ if argv and argv[0] == "list":
+ from harborrl.math_registry import list_components
+
+ choices = {"models", "algorithms", "tasks", "harnesses"}
+ if len(argv) != 2 or argv[1] not in choices:
+ parser = argparse.ArgumentParser(prog="harborrl list")
+ parser.error("choose one of: " + ", ".join(sorted(choices)))
+ print(json.dumps(list_components()[argv[1]], indent=2))
+ return 0
if argv and argv[0] == "hub":
from harborrl.tasks.cli import main as hub_main
return hub_main(argv[1:])
parser = argparse.ArgumentParser(
- prog="harborrl", description=__doc__, allow_abbrev=False
+ prog="harborrl", description=__doc__, allow_abbrev=False,
+ epilog="Configuration tools: harborrl options --help; harborrl validate --help; harborrl init --help; harborrl web --help."
)
parser.add_argument("-model", help="Slime model preset to select")
parser.add_argument("-harness", help="agent harness to select")
@@ -24,10 +41,13 @@ def main(argv=None):
parser.add_argument("command", choices=["train", "doctor"])
parser.add_argument("config_path", nargs="?", help="YAML configuration file")
parser.add_argument("--config", dest="config_option", help="YAML configuration file")
+ parser.add_argument("--deployment", help="deployment YAML describing the manager, GPU target and rollout workers")
parser.add_argument("--set", action="append", default=[])
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args(argv)
from harborrl.config import launch_plan, load_config
+ from harborrl.deployment.schema import embedded_local, load_deployment
+ from harborrl.deployment.schema import summary as deployment_summary
if args.config_path and args.config_option:
parser.error(
@@ -46,20 +66,36 @@ def main(argv=None):
if args.harness is not None:
overrides.append(f"harness.name={args.harness.replace('-', '_')}")
if args.alg is not None:
- contract = ALGORITHM_CONTRACTS.get(args.alg)
- if contract is None:
+ from harborrl.math_registry import MATH_REGISTRY
+
+ matches = [
+ spec for spec in MATH_REGISTRY.algorithms.items()
+ if args.alg in (spec.id,)
+ ]
+ if not matches:
parser.error(
f"unsupported algorithm: {args.alg}; "
- f"supported algorithms: {', '.join(sorted(ALGORITHM_CONTRACTS))}"
+ f"supported algorithms: "
+ f"{', '.join(spec.id for spec in MATH_REGISTRY.algorithms.items())}"
)
+ contract = next(iter(matches[0].training_backend_contracts))
overrides.append(f"training.backend_contract={contract}")
try:
- plan = launch_plan(load_config(
+ config = load_config(
config_path,
overrides,
task_ids=None if args.task is None else [args.task],
- ))
+ )
+ deployment, deployment_source = load_deployment(args.deployment)
+ if deployment_source == "embedded-local":
+ deployment = embedded_local(
+ run_root=str(Path(config["output"]["root"]).resolve()),
+ workers=config["harbor"]["workers"],
+ expected_gpus=config["deployment"]["num_gpus"],
+ )
+ plan = launch_plan(config)
+ plan["deployment"] = deployment_summary(deployment, deployment_source)
except (TypeError, ValueError, OSError) as exc:
parser.error(str(exc))
if args.dry_run:
diff --git a/harborrl/config/native.py b/harborrl/config/native.py
index 227f6e4d3..50b3ddb86 100644
--- a/harborrl/config/native.py
+++ b/harborrl/config/native.py
@@ -1,19 +1,18 @@
"""Strict native schema 2; local paths resolve relative to the config file."""
-import json
import os
import re
-import sys
from pathlib import Path
from urllib.parse import urlsplit
import yaml
-from harborrl.rollout.harbor_job.bindings import claude_config
-from harborrl.trajectories.native import RewardProfile
+from harborrl.consistency.config import DEFAULTS as CONSISTENCY_DEFAULTS
+from harborrl.consistency.config import validate as validate_consistency
FIELDS = {
+ 'consistency': set(CONSISTENCY_DEFAULTS),
'execution': {'backend'}, 'tasks': {'catalog'}, 'harness': {'name', 'profile'},
- 'harbor': {'python', 'version', 'workers'},
+ 'harbor': {'python', 'version', 'workers', 'runner_transport', 'environment'},
'gateway': {'host', 'port', 'advertised_url', 'tokenizer_digest', 'template_digest', 'audited_raw_logprobs'},
'model': {'checkpoint', 'reference', 'args_file'},
'training': {'num_rollout', 'learning_rate', 'save_interval', 'backend_contract'},
@@ -21,6 +20,7 @@
'deployment': {'layout', 'num_gpus', 'actor_gpus', 'rollout_gpus', 'actor_tensor_parallel_size', 'rollout_gpus_per_engine'},
'output': {'root'},
}
+OPTIONAL = {'harbor': {'runner_transport', 'environment'}}
class SelectedConfig(dict):
@@ -32,7 +32,10 @@ def __init__(self, config, task_ids):
def load_config(path, overrides=None, task_ids=None):
"""Load one schema-2 YAML file and apply strict ``section.key=value`` overrides."""
path = Path(path).resolve()
- config = yaml.safe_load(path.read_text())
+ try:
+ config = yaml.safe_load(path.read_text())
+ except yaml.YAMLError as exc:
+ raise ValueError(f'configuration YAML: {exc}') from exc
if not isinstance(config, dict):
raise TypeError('configuration must be a mapping')
if config.get('schema_version') != 2:
@@ -45,8 +48,12 @@ def load_config(path, overrides=None, task_ids=None):
if (not separator or field not in FIELDS.get(section_name, ())
or not isinstance(section, dict)):
raise ValueError(f'unknown native override: {key}')
- section[field] = yaml.safe_load(value)
+ try:
+ section[field] = yaml.safe_load(value)
+ except yaml.YAMLError as exc:
+ raise ValueError(f'{key}: invalid YAML override') from exc
config = validate(config, path)
+ validate_files(config)
if task_ids is None:
return config
if (not isinstance(task_ids, (list, tuple)) or not task_ids
@@ -57,16 +64,19 @@ def load_config(path, overrides=None, task_ids=None):
def validate(config, path):
- from harborrl.trajectories.native import read_json
+ if not isinstance(config, dict):
+ raise TypeError('configuration must be a mapping')
+ config['consistency'] = validate_consistency(config.get('consistency'))
if set(config) != set(FIELDS) | {'schema_version'} or type(config['schema_version']) is not int or config['schema_version'] != 2:
raise ValueError('native schema requires exactly the documented schema 2 sections')
+ if not isinstance(config['training'], dict):
+ raise TypeError('training must be a mapping')
config['training'].setdefault(
'backend_contract', 'slime-v0.3.2-native-v1')
for section, fields in FIELDS.items():
- if not isinstance(config[section], dict) or set(config[section]) != fields:
+ actual = set(config[section]) if isinstance(config[section], dict) else set()
+ if not isinstance(config[section], dict) or not fields - OPTIONAL.get(section, set()) <= actual <= fields:
raise ValueError(f'{section} requires exactly {sorted(fields)}')
- if config['execution']['backend'] != 'harbor_job' or config['harness']['name'] != 'claude_code' or config['harbor']['version'] != '0.23.0':
- raise ValueError('native requires harbor_job / claude_code / Harbor 0.23.0')
for section, key in [('tasks','catalog'), ('harness','profile'), ('harbor','python'),
('model','checkpoint'), ('model','reference'), ('output','root')]:
value = config[section][key]
@@ -82,133 +92,90 @@ def validate(config, path):
raise ValueError(f'{section}.{key} must be a positive integer')
from harborrl.backends.slime_v032.versions import CONFIG_CONTRACTS
from harborrl.trajectories.native import finite
- if config['training']['backend_contract'] not in CONFIG_CONTRACTS:
+ if (not isinstance(config['training']['backend_contract'], str)
+ or config['training']['backend_contract'] not in CONFIG_CONTRACTS):
raise ValueError(f"training.backend_contract must be one of {sorted(CONFIG_CONTRACTS)}")
if not finite(config['training']['learning_rate']) or config['training']['learning_rate'] <= 0:
- raise ValueError('positive finite learning_rate required')
+ raise ValueError('training.learning_rate must be positive and finite')
g, d, s = config['gateway'], config['deployment'], config['sampling']
if type(g['audited_raw_logprobs']) is not bool or not 0 < g['port'] < 65536:
- raise ValueError('invalid gateway audit flag or port')
+ raise ValueError('gateway.audited_raw_logprobs must be boolean and gateway.port must be in 1..65535')
for key in ('host','tokenizer_digest','template_digest'):
if not isinstance(g[key], str) or not g[key].strip():
raise ValueError(f'gateway.{key} required')
+ if not isinstance(g['advertised_url'], str):
+ raise TypeError('gateway.advertised_url must be a string')
advertised = urlsplit(g['advertised_url'])
if (advertised.scheme not in ('http', 'https') or not advertised.hostname
or advertised.username or advertised.password or advertised.query or advertised.fragment
or advertised.path not in ('', '/') or advertised.port != g['port']):
raise ValueError('advertised gateway must be a plain origin and match listener port')
- if s['group_size'] < 2 or s['max_tokens'] >= s['max_context']:
- raise ValueError('invalid group or context budget')
- if d['layout'] not in ('split','colocate'):
+ if s['group_size'] < 2:
+ raise ValueError('sampling.group_size must be at least 2')
+ if s['max_tokens'] >= s['max_context']:
+ raise ValueError('sampling.max_tokens must be below sampling.max_context')
+ if not isinstance(d['layout'], str) or d['layout'] not in ('split','colocate'):
raise ValueError('layout must be split or colocate')
if d['layout'] == 'split' and d['actor_gpus'] + d['rollout_gpus'] > d['num_gpus']:
- raise ValueError('GPU budget exceeded')
+ raise ValueError('GPU budget exceeded: deployment.actor_gpus + deployment.rollout_gpus > deployment.num_gpus')
if d['layout'] == 'colocate' and not d['actor_gpus'] == d['rollout_gpus'] == d['num_gpus']:
raise ValueError('colocate GPU counts must match')
if d['actor_gpus'] % d['actor_tensor_parallel_size'] or d['rollout_gpus'] % d['rollout_gpus_per_engine']:
- raise ValueError('GPU counts must be divisible by TP')
+ raise ValueError('deployment GPU counts must be divisible by their tensor parallel sizes')
preset = config['model']['args_file']
if not isinstance(preset,str) or not re.fullmatch(r'[A-Za-z0-9_-]+',preset):
raise ValueError('model.args_file must name a Slime model preset; SLIME_DIR is checked by doctor')
for key in ('tokenizer_digest', 'template_digest'):
if not re.fullmatch(r'[0-9a-f]{64}', g[key]):
raise ValueError(f'gateway.{key} must be a SHA-256 digest')
- workers = config['harbor']['workers']
- if (not isinstance(workers, list) or not workers or any(
+ harbor = config['harbor']
+ transport = harbor.get('runner_transport', 'ssh')
+ if transport not in ('ssh', 'local'):
+ raise ValueError('harbor.runner_transport must be ssh or local')
+ environment = harbor.get('environment', {'runtime': 'docker'})
+ if not isinstance(environment, dict) or environment.get('runtime') not in ('docker', 'sandbox'):
+ raise ValueError('harbor.environment.runtime must be docker or sandbox')
+ expected = {'runtime', 'config'} if environment['runtime'] == 'sandbox' else {'runtime'}
+ if set(environment) != expected:
+ raise ValueError('invalid harbor.environment fields')
+ if environment['runtime'] == 'sandbox':
+ if not isinstance(environment['config'], str) or not environment['config']:
+ raise ValueError('sandbox config path required')
+ environment['config'] = str((Path(path).parent / environment['config']).resolve())
+ workers = harbor['workers']
+ if (not isinstance(workers, list) or (transport == 'ssh' and not workers) or any(
not isinstance(worker, str) or not re.fullmatch(r'[A-Za-z0-9_.:+@-]+', worker) or worker.startswith('-')
for worker in workers)):
raise ValueError('harbor.workers must contain nonempty SSH destinations')
- claude_config(read_json(config['harness']['profile']), g['advertised_url'], 'validation-only')
+ if transport == 'local' and workers:
+ raise ValueError('local runner requires empty harbor.workers')
+ from harborrl.math_registry import select_math, validate_combination
+ validate_combination(select_math(config), config)
return config
-def catalog(config):
- from harborrl.data.harbor.native_inspector import inspect_native
+def validate_files(config):
+ """Check harness profile files separately from pure schema validation."""
+ from harborrl.math_registry import select_math
from harborrl.trajectories.native import read_json
- path = Path(config['tasks']['catalog'])
- entries = read_json(path)
- if not isinstance(entries,list) or not entries:
- raise ValueError('catalog must be a nonempty JSON list')
- seen = set()
- for entry in entries:
- if (set(entry) != {'id','revision','path','task_digest','reward_profile'}
- or not isinstance(entry['id'], str) or not entry['id'].strip()
- or not isinstance(entry['revision'], str) or not entry['revision'].strip()
- or entry['id'] in seen):
- raise ValueError('invalid or duplicate catalog identity')
- if (not isinstance(entry['path'], str) or not entry['path'].strip()
- or not isinstance(entry['task_digest'], str)
- or not re.fullmatch(r'[0-9a-f]{64}', entry['task_digest'])
- or not isinstance(entry['reward_profile'], dict)):
- raise ValueError(f'invalid native task lock: {entry["id"]}')
- seen.add(entry['id'])
- entry['path'] = str((path.parent / entry['path']).resolve())
- report = inspect_native(entry['path'], expected_digest=entry['task_digest'])
- if report['status'] != 'NEEDS_PROBE':
- raise ValueError(f"task preflight failed: {entry['id']}: {report['reasons']}")
- RewardProfile(**entry['reward_profile'])
- selected = set(getattr(config, 'task_ids', ()))
- if selected:
- missing = selected - {entry['id'] for entry in entries}
- if missing:
- raise ValueError(f'task selector not found in catalog: {min(missing)}')
- entries = [entry for entry in entries if entry['id'] in selected]
- return entries
+ harness = select_math(config).harness
+ try:
+ harness.adapter.validate_profile(
+ read_json(config['harness']['profile']),
+ config['gateway']['advertised_url'],
+ )
+ except (TypeError, ValueError, KeyError) as exc:
+ raise ValueError(f'harness.profile: {exc}') from exc
+ return config
+
+
+def catalog(config):
+ """Compatibility entry point for task lock validation."""
+ from harborrl.tasks.catalog import catalog as inspect_catalog
+ return inspect_catalog(config)
def launch_plan(config):
- from harborrl.backends.slime_v032.versions import runtime_contract_id
- from harborrl.config import ROOT
- catalog(config)
- c = config
- worker_overrides = os.environ.get('HARBORRL_NATIVE_TASK_WORKER_OVERRIDES', '{}')
- try:
- overrides = json.loads(worker_overrides)
- valid_workers = set(c['harbor']['workers'])
- if (not isinstance(overrides, dict) or any(not isinstance(key, str) or value not in valid_workers
- for key, value in overrides.items())):
- raise ValueError
- except (ValueError, json.JSONDecodeError) as exc:
- raise ValueError('invalid HARBORRL_NATIVE_TASK_WORKER_OVERRIDES') from exc
- environment = {
- 'DATASET': 'native', 'ALGO': 'grpo', 'HARNESS_OPTION': 'native-claude-code',
- 'HF_CKPT': c['model']['checkpoint'], 'REF_LOAD': c['model']['reference'],
- 'MODEL_ARGS_FILE': c['model']['args_file'], 'MODEL_TAG': c['model']['args_file'],
- 'NUM_ROLLOUT': c['training']['num_rollout'], 'SAVE_INTERVAL': c['training']['save_interval'],
- 'TRAIN_PYTHON': sys.executable,
- 'ROLLOUT_BATCH_SIZE': c['sampling']['groups_per_batch'],
- 'N_SAMPLES': c['sampling']['group_size'],
- 'ROLLOUT_MAX_RESPONSE_LEN': c['sampling']['max_tokens'],
- 'ROLLOUT_MAX_CONTEXT_LEN': c['sampling']['max_context'],
- 'NUM_GPUS': c['deployment']['num_gpus'], 'ACTOR_GPUS': c['deployment']['actor_gpus'],
- 'ROLLOUT_GPUS': c['deployment']['rollout_gpus'],
- 'TP_SIZE': c['deployment']['actor_tensor_parallel_size'],
- 'ROLLOUT_NUM_GPUS_PER_ENGINE': c['deployment']['rollout_gpus_per_engine'],
- 'HARBORRL_GPU_LAYOUT': c['deployment']['layout'],
- 'HARBORRL_STRUCTURED_LAUNCH': '1', 'HARBORRL_VERIFY_POLICY_POOL': '1',
- 'HARBORRL_SKIP_GLOBAL_CLEANUP': '1', 'SLIME_RAY_PLACEMENT_GPU_PROBE': '1',
- 'PYTORCH_CUDA_ALLOC_CONF': ('max_split_size_mb:2048' if c['deployment']['layout'] == 'colocate'
- else 'max_split_size_mb:2048,expandable_segments:True'),
- 'HARBORRL_NATIVE_ROLLOUT': '1',
- 'HARBORRL_NATIVE_SLIME_CONTRACT': runtime_contract_id(c['training']['backend_contract']),
- 'HARBORRL_NATIVE_RUNNER_PYTHON': c['harbor']['python'],
- 'HARBORRL_NATIVE_RUNNER_WORKERS': json.dumps(c['harbor']['workers']),
- 'HARBORRL_NATIVE_TASK_WORKER_OVERRIDES': json.dumps(overrides, sort_keys=True),
- 'HARBORRL_NATIVE_PROFILE': c['harness']['profile'],
- 'HARBORRL_NATIVE_CATALOG': c['tasks']['catalog'],
- 'HARBORRL_NATIVE_GATEWAY_HOST': c['gateway']['host'],
- 'HARBORRL_NATIVE_GATEWAY_PORT': c['gateway']['port'],
- 'HARBORRL_NATIVE_GATEWAY_URL': c['gateway']['advertised_url'],
- 'HARBORRL_NATIVE_TOKENIZER_DIGEST': c['gateway']['tokenizer_digest'],
- 'HARBORRL_NATIVE_TEMPLATE_DIGEST': c['gateway']['template_digest'],
- 'HARBORRL_NATIVE_AUDITED_LOGPROBS': str(int(c['gateway']['audited_raw_logprobs'])),
- 'HARBORRL_NATIVE_MAX_ATTEMPTS': c['sampling']['max_attempts'],
- 'HARBORRL_NATIVE_DRAIN_TIMEOUT': '30',
- 'RUNS_ROOT': c['output']['root'], 'WANDB_ENABLE': '0',
- }
- return {'config': config, 'command': [sys.executable, '-m', 'harborrl.platform.native_train'],
- 'environment': {key: str(value) for key, value in environment.items()},
- 'training_command': ['bash', str(ROOT / 'harborrl/platform/slime_train.sh')],
- 'required_services': ['GPU Ray / SGLang / Messages Gateway', 'external CPU Harbor Docker workers'],
- 'launcher': 'native Claude Code → Messages Gateway → Harbor verifier → Slime GRPO',
- 'layout': {'mode': config['deployment']['layout'], 'offload': config['deployment']['layout']=='colocate'}}
+ """Compatibility entry point for backend launch mapping."""
+ from harborrl.backends.slime_v032.config import launch_plan as build_plan
+ return build_plan(config)
diff --git a/harborrl/consistency/__init__.py b/harborrl/consistency/__init__.py
new file mode 100644
index 000000000..8031995e5
--- /dev/null
+++ b/harborrl/consistency/__init__.py
@@ -0,0 +1 @@
+"""Backend consistency evidence, independent of optimization objectives."""
diff --git a/harborrl/consistency/actor.py b/harborrl/consistency/actor.py
new file mode 100644
index 000000000..e332fc4d1
--- /dev/null
+++ b/harborrl/consistency/actor.py
@@ -0,0 +1,88 @@
+"""Small Slime actor extension: snapshot trained-model canaries before weight sync."""
+import os
+from pathlib import Path
+
+import torch
+import torch.distributed as dist
+from slime.backends.megatron_utils.actor import MegatronTrainRayActor
+from slime.backends.megatron_utils.data import DataIterator
+from slime.backends.megatron_utils.loss import get_log_probs_and_entropy
+from slime.backends.megatron_utils.model import forward_only
+from slime.backends.megatron_utils.update_weight.common import named_params_and_buffers
+from slime.utils.distributed_utils import get_gloo_group
+from slime.utils.reloadable_process_group import (
+ destroy_process_groups,
+ reload_process_groups,
+)
+
+from harborrl.trajectories.native import digest, publish
+
+
+class ConsistencyActor(MegatronTrainRayActor):
+ def update_weights(self):
+ if self.args.offload_train:
+ self.wake_up()
+ self._switch_model("actor")
+ from harborrl.rollout.runtime import tokenizer_fingerprint
+ error = None
+ try:
+ actual = tokenizer_fingerprint(self.tokenizer)
+ expected = (os.environ['HARBORRL_NATIVE_TOKENIZER_DIGEST'], os.environ['HARBORRL_NATIVE_TEMPLATE_DIGEST'])
+ if actual != expected:
+ raise ValueError('Trainer tokenizer/template differs from rollout launch identity')
+ except (ValueError, TypeError, KeyError) as exc:
+ error = str(exc)
+ errors = [None] * dist.get_world_size()
+ dist.all_gather_object(errors, error, group=get_gloo_group())
+ if any(errors):
+ raise ValueError(f'Invalid trainer identity: {errors}')
+ cases = []
+ # Literal token fixtures intentionally bypass all serving-side text templating.
+ for prompt, answer in (("Compute 1 + 1 =", " 2.\n"), ("def square(x):\n", " return x * x\n")):
+ p = self.tokenizer.encode(prompt, add_special_tokens=True)
+ r = self.tokenizer.encode(answer, add_special_tokens=False)
+ if not p or not r:
+ raise ValueError("Tokenizer produced an empty canary")
+ cases.append({"prompt_ids": p, "response_ids": r})
+ device = next(self.model[0].parameters()).device
+ data = {"tokens": [torch.tensor(c["prompt_ids"] + c["response_ids"], device=device) for c in cases],
+ "loss_masks": [torch.ones(len(c["response_ids"]), dtype=torch.int, device=device) for c in cases],
+ "total_lengths": [len(c["prompt_ids"]) + len(c["response_ids"]) for c in cases],
+ "response_lengths": [len(c["response_ids"]) for c in cases]}
+ output = forward_only(get_log_probs_and_entropy, self.args, self.model,
+ [DataIterator(data, [[i] for i in range(len(cases))])], [len(cases)])
+ for case, values in zip(cases, output["log_probs"], strict=True):
+ case["logprobs"] = values.detach().float().cpu().tolist()
+ fingerprints = []
+ for name, parameter in named_params_and_buffers(self.args, self.model):
+ flat = parameter.detach().reshape(-1)
+ indices = torch.linspace(0, max(0, flat.numel() - 1), min(16, flat.numel()), device=flat.device).long()
+ fingerprints.append({"name": name, "shape": list(parameter.shape), "dtype": str(parameter.dtype),
+ "sample": flat[indices].float().cpu().tolist()})
+ local = {"rank": dist.get_rank(), "fingerprint": digest(fingerprints), "tensor_count": len(fingerprints)}
+ shards = [None] * dist.get_world_size()
+ dist.all_gather_object(shards, local, group=get_gloo_group())
+ config = self.hf_config.to_dict()
+ reference = {"schema_version": "harborrl-sync-reference-v1", "cases": cases,
+ "model_config_digest": digest(config), "model_config": config,
+ "tokenizer_vocabulary_digest": digest(sorted(self.tokenizer.get_vocab().items())),
+ "parameter_shards": shards, "fingerprint_semantics": "16 deterministic values per tensor; not a full checksum"}
+ if self.args.offload_train:
+ self.sleep()
+ super().update_weights()
+ if self.args.offload_train:
+ reload_process_groups()
+ reference["weight_version"] = str(self.weight_updater.weight_version)
+ reference["digest"] = digest(reference)
+ error = None
+ if dist.get_rank() == 0:
+ try:
+ publish(Path(os.environ["RUN_DIR"]) / "consistency" / f"sync-{reference['weight_version']}.json", reference)
+ except (OSError, ValueError) as exc:
+ error = str(exc)
+ errors = [None] * dist.get_world_size()
+ dist.all_gather_object(errors, error, group=get_gloo_group())
+ if self.args.offload_train:
+ destroy_process_groups()
+ if any(errors):
+ raise ValueError(f"Failed to publish synchronized model evidence: {errors}")
diff --git a/harborrl/consistency/config.py b/harborrl/consistency/config.py
new file mode 100644
index 000000000..f2317e49a
--- /dev/null
+++ b/harborrl/consistency/config.py
@@ -0,0 +1,51 @@
+"""Explicit deployment-specific tolerances; no universal numeric threshold."""
+import json
+import os
+
+from harborrl.trajectories.native import finite
+
+DEFAULTS = {"mode": "off", "max_abs_delta": None, "max_mean_abs_delta": None,
+ "bad_token_delta": None, "max_bad_fraction": 0.0, "worst_tokens": 8}
+
+
+def validate(value=None):
+ if value is None:
+ value = {}
+ if not isinstance(value, dict) or set(value) - set(DEFAULTS):
+ raise ValueError("Unknown consistency configuration fields")
+ result = {**DEFAULTS, **value}
+ if result["mode"] not in ("off", "audit", "enforce"):
+ raise ValueError("consistency.mode must be off, audit or enforce")
+ for key in ("max_abs_delta", "max_mean_abs_delta", "bad_token_delta"):
+ if result[key] is not None and (not finite(result[key]) or result[key] < 0):
+ raise ValueError(f"consistency.{key} must be nonnegative and finite")
+ if result["mode"] == "enforce" and result["max_abs_delta"] is None:
+ raise ValueError("enforce requires an explicit max_abs_delta for this deployment")
+ if not finite(result["max_bad_fraction"]) or not 0 <= result["max_bad_fraction"] <= 1:
+ raise ValueError("max_bad_fraction must be in [0, 1]")
+ if type(result["worst_tokens"]) is not int or not 0 <= result["worst_tokens"] <= 32:
+ raise ValueError("worst_tokens must be an integer in [0, 32]")
+ return result
+
+
+def environment():
+ return validate(json.loads(os.environ.get("HARBORRL_CONSISTENCY", "{}")))
+
+
+def require_raw_sampling(sampling):
+ allowed = {"temperature", "top_p", "top_k", "max_new_tokens", "stop", "raw_logprobs"}
+ if (not isinstance(sampling, dict) or set(sampling) - allowed
+ or sampling.get("temperature") != 1 or sampling.get("top_p") != 1
+ or sampling.get("top_k", -1) not in (-1, 0)
+ or sampling.get("raw_logprobs", True) is not True):
+ raise ValueError("Consistency v1 requires untransformed raw-policy sampling")
+
+
+def violations(metrics, config):
+ errors = []
+ for option, field in (("max_abs_delta", "max_abs_delta"), ("max_mean_abs_delta", "mean_abs_delta")):
+ if config[option] is not None and metrics[field] > config[option]:
+ errors.append(f"{field}={metrics[field]:.8g} exceeds {config[option]}")
+ if config["bad_token_delta"] is not None and metrics["bad_fraction"] > config["max_bad_fraction"]:
+ errors.append(f"bad_fraction={metrics['bad_fraction']:.8g} exceeds {config['max_bad_fraction']}")
+ return errors
diff --git a/harborrl/consistency/metrics.py b/harborrl/consistency/metrics.py
new file mode 100644
index 000000000..4057e8c97
--- /dev/null
+++ b/harborrl/consistency/metrics.py
@@ -0,0 +1,67 @@
+"""Mergeable token statistics with bounded-memory percentile upper bounds."""
+import bisect
+import math
+
+from harborrl.trajectories.native import finite
+
+# Logarithmic buckets retain sub-micro precision near zero, with no token all-gather.
+EDGES = [0.0, *[2 ** (-24 + i / 8) for i in range(321)]]
+
+
+def statistics(rows, *, threshold=None, worst=8):
+ result = {"count": 0, "signed_sum": 0.0, "abs_sum": 0.0, "max_abs_delta": 0.0,
+ "min_log_ratio": None, "max_log_ratio": None, "bad_count": 0,
+ "histogram": [0] * (len(EDGES) + 1), "worst": []}
+ for row_index, (current, behavior, mask) in enumerate(rows):
+ if len(current) != len(behavior) or len(mask) != len(current) or not len(current):
+ raise ValueError("Probability/mask length mismatch")
+ if any(flag not in (0, 1) for flag in mask):
+ raise ValueError("Loss mask must be binary")
+ for position, (p, q, flag) in enumerate(zip(current, behavior, mask, strict=True)):
+ if not finite(p) or not finite(q) or p > 0 or q > 0:
+ raise ValueError("Log probabilities must be finite and nonpositive")
+ if not flag:
+ continue
+ delta = float(p) - float(q)
+ absolute = abs(delta)
+ if not math.isfinite(delta):
+ raise ValueError("Nonfinite log-probability difference")
+ result["count"] += 1
+ result["signed_sum"] += delta
+ result["abs_sum"] += absolute
+ result["max_abs_delta"] = max(result["max_abs_delta"], absolute)
+ result["min_log_ratio"] = delta if result["min_log_ratio"] is None else min(result["min_log_ratio"], delta)
+ result["max_log_ratio"] = delta if result["max_log_ratio"] is None else max(result["max_log_ratio"], delta)
+ result["bad_count"] += int(threshold is not None and absolute > threshold)
+ result["histogram"][bisect.bisect_left(EDGES, absolute)] += 1
+ if worst:
+ result["worst"].append({"row": row_index, "position": position, "delta": delta})
+ result["worst"] = sorted(result["worst"], key=lambda x: abs(x["delta"]), reverse=True)[:worst]
+ if not result["count"]:
+ raise ValueError("No valid response tokens")
+ if not math.isfinite(result["abs_sum"]) or not math.isfinite(result["signed_sum"]):
+ raise ValueError("Probability statistics overflow")
+ return result
+
+
+def merge(parts):
+ if not parts:
+ raise ValueError("No contributing ranks")
+ count = sum(p["count"] for p in parts)
+ bins = [sum(p["histogram"][i] for p in parts) for i in range(len(EDGES) + 1)]
+ maximum = max(p["max_abs_delta"] for p in parts)
+ def percentile(fraction):
+ target, seen = math.ceil(count * fraction), 0
+ for index, n in enumerate(bins):
+ seen += n
+ if seen >= target:
+ return min(EDGES[index], maximum) if index < len(EDGES) else maximum
+ raise ValueError("Corrupt histogram")
+ return {"token_count": count, "signed_mean_delta": math.fsum(p["signed_sum"] for p in parts) / count,
+ "mean_abs_delta": math.fsum(p["abs_sum"] for p in parts) / count,
+ "max_abs_delta": maximum, "p95_abs_delta_upper": percentile(.95), "p99_abs_delta_upper": percentile(.99),
+ "min_log_ratio": min(p["min_log_ratio"] for p in parts),
+ "max_log_ratio": max(p["max_log_ratio"] for p in parts),
+ "bad_fraction": sum(p["bad_count"] for p in parts) / count,
+ "percentile_semantics": "logarithmic-histogram upper bound; not exact quantiles",
+ "gap_semantics": "fixed-prefix token log-ratio; not full-sequence KL"}
diff --git a/harborrl/consistency/probe.py b/harborrl/consistency/probe.py
new file mode 100644
index 000000000..a218337b1
--- /dev/null
+++ b/harborrl/consistency/probe.py
@@ -0,0 +1,35 @@
+"""Replay a Megatron fixed-token reference against an explicit engine pool."""
+import argparse
+import json
+
+from harborrl.trajectories.native import digest, publish, read_json
+
+from .config import validate
+from .sglang import check_pool, http_json
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--reference', required=True)
+ parser.add_argument('--engine', action='append', required=True)
+ parser.add_argument('--config', required=True, help='JSON consistency mapping with explicit tolerances')
+ parser.add_argument('--output', required=True)
+ args = parser.parse_args(argv)
+ settings = validate(read_json(args.config))
+ if settings['mode'] == 'off':
+ parser.error('probe mode must be audit or enforce')
+ reference = read_json(args.reference)
+ if reference.get('digest') != digest({k: v for k, v in reference.items() if k != 'digest'}):
+ raise ValueError('Corrupt sync reference')
+ states = [{'endpoint': endpoint.rstrip('/'), 'healthy': True,
+ 'weight_version': http_json(endpoint.rstrip('/') + '/get_weight_version')['weight_version']}
+ for endpoint in args.engine]
+ report = check_pool(states, reference, settings)
+ report['reference_digest'] = reference['digest']
+ publish(args.output, report)
+ print(json.dumps(report, indent=2))
+ return int(report['status'] == 'failed')
+
+
+if __name__ == '__main__':
+ raise SystemExit(main())
diff --git a/harborrl/consistency/report.py b/harborrl/consistency/report.py
new file mode 100644
index 000000000..0b3962894
--- /dev/null
+++ b/harborrl/consistency/report.py
@@ -0,0 +1,51 @@
+"""Collect errors before raising so every participating rank makes the same decision."""
+from pathlib import Path
+
+from harborrl.trajectories.native import publish
+
+from .config import violations
+from .metrics import merge, statistics
+
+
+def assess(rows, config, *, metadata=None):
+ try:
+ return {"statistics": statistics(rows, threshold=config["bad_token_delta"], worst=config["worst_tokens"]),
+ "error": None, "metadata": metadata or {}}
+ except (ValueError, TypeError, OverflowError) as exc:
+ return {"statistics": None, "error": f"{type(exc).__name__}: {exc}", "metadata": metadata or {}}
+
+
+def decide(parts, config, *, phase):
+ structural = [p["error"] for p in parts if p["error"]]
+ contributing = [p for p in parts if p.get("contribute", True)]
+ metrics = None if structural else merge([p["statistics"] for p in contributing])
+ numeric = violations(metrics, config) if metrics else []
+ return {"schema_version": "harborrl-consistency-v1", "phase": phase, "mode": config["mode"],
+ "status": "failed" if structural or (numeric and config["mode"] == "enforce") else "warning" if numeric else "passed",
+ "structural_errors": structural, "numeric_violations": numeric, "metrics": metrics,
+ "config": config, "ranks": [{"metadata": p["metadata"], "error": p["error"],
+ "contribute": p.get("contribute", True),
+ "worst": p["statistics"]["worst"] if p["statistics"] else []} for p in parts]}
+
+
+def distributed_gate(part, config, path, *, phase, group=None):
+ import torch.distributed as dist
+ active = dist.is_available() and dist.is_initialized()
+ parts = [part]
+ if active:
+ parts = [None] * dist.get_world_size(group)
+ dist.all_gather_object(parts, part, group=group)
+ report = decide(parts, config, phase=phase)
+ write_error = None
+ if not active or dist.get_rank(group) == 0:
+ try:
+ publish(Path(path), report)
+ except (OSError, ValueError) as exc:
+ write_error = str(exc)
+ if active:
+ errors = [None] * dist.get_world_size(group)
+ dist.all_gather_object(errors, write_error, group=group)
+ write_error = next((error for error in errors if error), None)
+ if write_error or report["status"] == "failed":
+ raise ValueError(f"Consistency {phase} rejected: {write_error or report['structural_errors'] or report['numeric_violations']}")
+ return report
diff --git a/harborrl/consistency/sglang.py b/harborrl/consistency/sglang.py
new file mode 100644
index 000000000..d9a5ac654
--- /dev/null
+++ b/harborrl/consistency/sglang.py
@@ -0,0 +1,92 @@
+"""Exact-token teacher-forcing probes against every synchronized inference engine."""
+import json
+from pathlib import Path
+from urllib import request
+
+from harborrl.trajectories.native import digest, finite, publish, read_json
+
+from .config import environment, violations
+from .metrics import merge
+from .report import assess, decide
+
+
+def http_json(url, payload=None, timeout=120):
+ opener = request.build_opener(request.ProxyHandler({}))
+ req = request.Request(url, data=json.dumps(payload).encode() if payload is not None else None,
+ headers={"Content-Type": "application/json"})
+ with opener.open(req, timeout=timeout) as response:
+ return json.load(response)
+
+
+def score(endpoint, prompt, response, version, *, transport=http_json):
+ tokens = prompt + response
+ payload = {"input_ids": tokens, "sampling_params": {"temperature": 1.0, "top_p": 1.0,
+ "top_k": -1, "max_new_tokens": 0}, "return_logprob": True, "logprob_start_len": 0}
+ output = transport(endpoint.rstrip("/") + "/generate", payload)
+ meta = output.get("meta_info", {})
+ if str(meta.get("weight_version", "")) != str(version):
+ raise ValueError("Canary generation-time weight version mismatch")
+ rows = meta.get("input_token_logprobs")
+ if (not isinstance(rows, list) or len(rows) != len(tokens)
+ or any(not isinstance(row, (tuple, list)) or len(row) < 2 for row in rows)
+ or [row[1] for row in rows] != tokens):
+ raise ValueError("Canary input logprobs must preserve every exact token and position")
+ values = [row[0] for row in rows[len(prompt):]]
+ if any(not finite(value) or value > 0 for value in values):
+ raise ValueError("Invalid canary raw logprob")
+ return values
+
+
+def check_pool(states, reference, config, *, transport=http_json):
+ expected = reference["weight_version"]
+ endpoints = [s["endpoint"] for s in states]
+ if not states or len(set(endpoints)) != len(endpoints):
+ raise ValueError("Canary requires a nonempty unique engine pool")
+ if any(not s.get("healthy") or str(s.get("weight_version")) != str(expected) for s in states):
+ raise ValueError("Canary pool contains stale or unhealthy engines")
+ parts = []
+ for endpoint in endpoints:
+ try:
+ rows = [(case["logprobs"], score(endpoint, case["prompt_ids"], case["response_ids"], expected,
+ transport=transport), [1] * len(case["response_ids"])) for case in reference["cases"]]
+ part = assess(rows, config, metadata={"engine": endpoint, "weight_version": expected})
+ # Detect a version transition across scoring, in addition to each response's version.
+ if str(transport(endpoint + "/get_weight_version").get("weight_version")) != str(expected):
+ raise ValueError("Engine changed weights during canary scoring")
+ except (OSError, ValueError, TypeError, KeyError) as exc:
+ part = {"statistics": None, "error": str(exc), "metadata": {"engine": endpoint}}
+ parts.append(part)
+ report = decide(parts, config, phase="synchronized-pool")
+ # Mean/fraction limits apply independently to every engine.
+ engine_errors = [f"{p['metadata']['engine']}: {error}" for p in parts if p['statistics']
+ for error in violations(merge([p['statistics']]), config)]
+ report['numeric_violations'] = engine_errors
+ if engine_errors and report['status'] != 'failed':
+ report['status'] = 'failed' if config['mode'] == 'enforce' else 'warning'
+ return report
+
+
+def admit_pool(states, run_dir):
+ config = environment()
+ if config["mode"] == "off":
+ return
+ versions = {str(s["weight_version"]) for s in states}
+ if len(versions) != 1:
+ raise ValueError("Cannot admit a mixed-version pool")
+ version = versions.pop()
+ if not version.isdecimal():
+ raise ValueError("Invalid numeric serving version")
+ root = Path(run_dir) / "consistency"
+ reference = read_json(root / f"sync-{version}.json")
+ if reference["weight_version"] != version or reference["digest"] != digest({k: v for k, v in reference.items() if k != "digest"}):
+ raise ValueError("Canary reference identity mismatch")
+ admission = digest({'states': states, 'config': config, 'reference': reference['digest']})
+ path = root / f"pool-{version}-{admission}.json"
+ if path.exists():
+ report = read_json(path)
+ else:
+ report = check_pool(states, reference, config)
+ report["reference_digest"] = reference["digest"]
+ publish(path, report)
+ if report["status"] == "failed":
+ raise ValueError(f"Serving canary rejected version {version}")
diff --git a/harborrl/consistency/slime.py b/harborrl/consistency/slime.py
new file mode 100644
index 000000000..6493e5ea1
--- /dev/null
+++ b/harborrl/consistency/slime.py
@@ -0,0 +1,74 @@
+"""Integration using the locked Slime precomputed log_probs and postprocess hook."""
+import os
+import struct
+from pathlib import Path
+
+from harborrl.trajectories.native import digest, publish, read_json
+
+from .config import environment, require_raw_sampling
+from .report import assess, distributed_gate
+
+
+def evidence_key(tokens, logprobs, mask):
+ q = [struct.unpack("!f", struct.pack("!f", value))[0] for value in logprobs]
+ return digest({"tokens": tokens, "q": q, "mask": mask})
+
+
+def record_turn(turn, tokens, logprobs, mask):
+ config = environment()
+ if config["mode"] == "off":
+ return
+ require_raw_sampling(turn["sampling"])
+ if turn["logprob_semantics"] != "raw_model" or turn["evidence_kind"] != "serving":
+ raise ValueError("Consistency requires observed raw serving evidence")
+ key = evidence_key(tokens, logprobs, mask)
+ version = str(turn["policy_version"])
+ if not version.isdecimal():
+ raise ValueError("Invalid serving version")
+ payload = {"key": key, "weight_version": version, "sampling": turn["sampling"],
+ "tokenizer_digest": turn["tokenizer_digest"], "template_digest": turn["template_digest"],
+ "logprob_semantics": "raw_model_equals_sampling", "evidence_kind": "serving"}
+ publish(Path(os.environ["RUN_DIR"]) / "consistency" / "turns" / version / f"{key}.json", payload)
+
+
+def identity_tis(*args, **kwargs):
+ raise RuntimeError("Consistency is metrics-only; invoking a TIS correction would change the Native objective")
+
+
+def check_training_batch(args, rollout_id, data, validator):
+ import torch.distributed as dist
+ from megatron.core import mpu
+ from slime.utils.distributed_utils import get_gloo_group
+
+ from harborrl.backends.slime_v032.versions import read_locked_policy_version
+
+ config = environment()
+ root = Path(os.environ["RUN_DIR"])
+ metadata = {"rank": dist.get_rank(), "rollout_id": rollout_id, "backend": "megatron",
+ "scope": "pre-update actual packed rollout batch"}
+ try:
+ validator(args, rollout_id, data)
+ if "log_probs" not in data or len(data["log_probs"]) != len(data["tokens"]):
+ raise ValueError("Missing pre-update Megatron log_probs; --get-mismatch-metrics is required")
+ version = read_locked_policy_version(root)
+ metadata["weight_version"] = version
+ cases = []
+ for tokens, q, mask, p0 in zip(data["tokens"], data["rollout_log_probs"], data["loss_masks"], data["log_probs"], strict=True):
+ tokens, q, mask, p0 = (x.detach().cpu().tolist() if hasattr(x, "detach") else list(x) for x in (tokens, q, mask, p0))
+ # Slime stores rollout probabilities as float32. Normalize the lookup identically.
+ key = evidence_key(tokens, q, mask)
+ evidence = read_json(root / "consistency" / "turns" / version / f"{key}.json")
+ if evidence["key"] != key or evidence["weight_version"] != version:
+ raise ValueError("Frozen serving evidence does not match the training batch")
+ for field, env in (("tokenizer_digest", "HARBORRL_NATIVE_TOKENIZER_DIGEST"),
+ ("template_digest", "HARBORRL_NATIVE_TEMPLATE_DIGEST")):
+ if evidence[field] != os.environ[env]:
+ raise ValueError(f"Training batch {field} differs from launch identity")
+ require_raw_sampling(evidence["sampling"])
+ cases.append((p0, q, mask))
+ part = assess(cases, config, metadata=metadata)
+ except (ValueError, TypeError, KeyError, OSError) as exc:
+ part = {"statistics": None, "error": str(exc), "metadata": metadata}
+ part["contribute"] = mpu.get_tensor_model_parallel_rank() == 0
+ distributed_gate(part, config, root / "consistency" / f"batch-{rollout_id}.json",
+ phase="pre-update-backend-gap", group=get_gloo_group())
diff --git a/harborrl/consistency/train.py b/harborrl/consistency/train.py
new file mode 100644
index 000000000..caaea02d9
--- /dev/null
+++ b/harborrl/consistency/train.py
@@ -0,0 +1,76 @@
+"""Run the locked upstream training loop with its supported actor-class factory."""
+import functools
+import importlib.util
+import json
+import os
+from pathlib import Path
+
+from .config import environment
+
+
+class CheckedActorGroup:
+ """Check every sync, including the final one, before returning to upstream."""
+ def __init__(self, actor, manager, args):
+ self.actor, self.manager, self.args = actor, manager, args
+
+ def __getattr__(self, name):
+ return getattr(self.actor, name)
+
+ def update_weights(self):
+ import ray
+
+ from harborrl.backends.slime_v032.rollout_service_client import (
+ fetch_pool_states,
+ )
+
+ from .sglang import admit_pool
+ result = self.actor.update_weights()
+ if self.args.offload_rollout:
+ ray.get(self.manager.onload_kv.remote())
+ endpoint = f"http://{self.args.sglang_router_ip}:{self.args.sglang_router_port}"
+ admit_pool(fetch_pool_states(endpoint), os.environ['RUN_DIR'])
+ return result
+
+
+def main():
+ import ray
+ from slime.ray.placement_group import create_training_models
+ from slime.utils.arguments import parse_args
+
+ from .actor import ConsistencyActor
+
+ args = parse_args()
+ for name, expected in (("pipeline_model_parallel_size", 1), ("context_parallel_size", 1),
+ ("update_weights_interval", 1)):
+ if getattr(args, name) != expected:
+ raise ValueError(f"Consistency v1 requires {name}={expected}")
+ if any(getattr(args, key, False) for key in ('use_critic', 'keep_old_actor', 'use_tis', 'release_train', 'use_rollout_routing_replay', 'num_experts')):
+ raise ValueError("Consistency v1 supports synchronous Native actor training without routing replay")
+ if args.update_weight_mode != "full" or args.update_weight_transport != "nccl":
+ raise ValueError("Consistency v1 requires full tensor/NCCL weight synchronization")
+ settings = environment()
+ rollout_env = {key: value for key, value in os.environ.items()
+ if key.startswith('HARBORRL_') or key in ('RUN_DIR', 'RUN_ID', 'MODEL_TAG', 'MODEL_ARGS_FILE',
+ 'ROLLOUT_MAX_RESPONSE_LEN', 'ROLLOUT_MAX_CONTEXT_LEN', 'PYTHONPATH')}
+ if not ray.is_initialized():
+ ray.init(runtime_env={'env_vars': rollout_env})
+ args.get_mismatch_metrics = True
+ args.custom_tis_function_path = "harborrl.consistency.slime.identity_tis"
+ args.train_env_vars = dict(args.train_env_vars or {})
+ args.train_env_vars.update({"HARBORRL_CONSISTENCY": json.dumps(settings), "RUN_DIR": os.environ["RUN_DIR"],
+ "HARBORRL_NATIVE_TOKENIZER_DIGEST": os.environ["HARBORRL_NATIVE_TOKENIZER_DIGEST"],
+ "HARBORRL_NATIVE_TEMPLATE_DIGEST": os.environ["HARBORRL_NATIVE_TEMPLATE_DIGEST"]})
+ spec = importlib.util.spec_from_file_location("harborrl_upstream_train", Path(os.environ["SLIME_DIR"]) / "train.py")
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ # Only the factory in this private entrypoint module is replaced; upstream files are untouched.
+ factory = functools.partial(create_training_models, actor_cls=ConsistencyActor)
+ def checked_factory(args, pgs, manager):
+ actor, critic = factory(args, pgs, manager)
+ return CheckedActorGroup(actor, manager, args), critic
+ module.create_training_models = checked_factory
+ module.train(args)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/harborrl/data/harbor/__main__.py b/harborrl/data/harbor/__main__.py
index 99d90d197..291463cbd 100644
--- a/harborrl/data/harbor/__main__.py
+++ b/harborrl/data/harbor/__main__.py
@@ -1,3 +1,4 @@
+"""Legacy TerminalEnv compatibility CLI; use harborrl hub inspect for native tasks."""
import argparse
import json
from pathlib import Path
diff --git a/harborrl/data/harbor/inspector.py b/harborrl/data/harbor/inspector.py
index 4a426fd4f..3499bdbd2 100644
--- a/harborrl/data/harbor/inspector.py
+++ b/harborrl/data/harbor/inspector.py
@@ -1,35 +1,16 @@
-"""Conservative inspection of pinned local Harbor tasks."""
+"""Legacy TerminalEnv compatibility tooling; native training does not use this path."""
-import hashlib
from dataclasses import asdict, dataclass
from pathlib import Path
-from .receipt import parse_reward
+from harborrl.tasks.content import tomllib, tree_digest # compatibility exports
-try:
- import tomllib
-except ModuleNotFoundError: # Python 3.10
- import tomli as tomllib
+from .receipt import parse_reward
PROFILE = "terminal_text_v1"
REWARD_PROFILE = "harbor_reward_txt_v1"
-def tree_digest(root: Path, *, exclude: tuple[str, ...] = ()) -> str:
- h = hashlib.sha256()
- for path in sorted(Path(root).rglob("*")):
- if str(path.relative_to(root)) in exclude:
- continue
- if path.is_symlink():
- raise ValueError("symlinks are not supported")
- if path.is_file():
- data = path.read_bytes()
- h.update(str(path.relative_to(root)).encode() + b"\0")
- h.update(str(path.stat().st_mode & 0o777).encode() + b"\0")
- h.update(str(len(data)).encode() + b"\0" + data)
- return h.hexdigest()
-
-
@dataclass(frozen=True)
class TaskRef:
dataset: str
diff --git a/harborrl/data/harbor/materializer.py b/harborrl/data/harbor/materializer.py
index 95346c414..ce0c59cec 100644
--- a/harborrl/data/harbor/materializer.py
+++ b/harborrl/data/harbor/materializer.py
@@ -1,4 +1,4 @@
-"""Publish a task without changing its verifier or Docker build context."""
+"""Legacy TerminalEnv compatibility tooling; native training does not use this path."""
import json
import shutil
diff --git a/harborrl/data/harbor/native_inspector.py b/harborrl/data/harbor/native_inspector.py
index de0a311b5..729f3c726 100644
--- a/harborrl/data/harbor/native_inspector.py
+++ b/harborrl/data/harbor/native_inspector.py
@@ -1,7 +1,7 @@
"""Native preflight: task metadata, not TerminalEnv materialization rules."""
from pathlib import Path
-from .inspector import tomllib, tree_digest
+from harborrl.tasks.content import tomllib, tree_digest
def inspect_native(path, *, expected_digest=None):
@@ -14,7 +14,7 @@ def inspect_native(path, *, expected_digest=None):
if expected_digest is not None and checksum != expected_digest:
raise ValueError("task content changed since catalog lock")
spec = tomllib.loads((root / "task.toml").read_text())
- for name in ("instruction.md", "environment/Dockerfile", "tests/test.sh"):
+ for name in ("instruction.md", "tests/test.sh"):
if not (root / name).is_file():
raise ValueError("missing " + name)
if not (root / "instruction.md").read_text().strip():
@@ -22,6 +22,8 @@ def inspect_native(path, *, expected_digest=None):
env, verifier = spec.get("environment", {}), spec.get("verifier", {})
if not isinstance(env, dict) or not isinstance(verifier, dict):
raise TypeError("invalid environment/verifier configuration")
+ if not (root / 'environment/Dockerfile').is_file() and not env.get('docker_image'):
+ raise ValueError('missing environment/Dockerfile or prebuilt docker_image')
if not isinstance(spec.get("agent", {}), dict):
raise TypeError("invalid agent configuration")
if spec.get("user_agent") or spec.get("bridge"):
diff --git a/harborrl/data/harbor/probe.py b/harborrl/data/harbor/probe.py
index 650941bb9..d3e11d948 100644
--- a/harborrl/data/harbor/probe.py
+++ b/harborrl/data/harbor/probe.py
@@ -1,11 +1,4 @@
-"""Probe a Harbor task with native Docker verifier semantics.
-
-The legacy TerminalEnv comparison path is intentionally not part of the public
-release. This probe runs the untouched task verifier twice in isolated
-containers and requires the same scalar reward, which keeps the check aligned
-with the Native Harbor Runner boundary without importing the old environment
-stack.
-"""
+"""Legacy TerminalEnv compatibility tooling; native training does not use this path."""
from __future__ import annotations
import asyncio
diff --git a/harborrl/data/harbor/receipt.py b/harborrl/data/harbor/receipt.py
index 425a8be4f..ad0476ab1 100644
--- a/harborrl/data/harbor/receipt.py
+++ b/harborrl/data/harbor/receipt.py
@@ -1,4 +1,4 @@
-"""Strict scalar receipt parsing: a missing/invalid score is an infra failure."""
+"""Legacy TerminalEnv compatibility tooling; native training does not use this path."""
import math
diff --git a/harborrl/deployment/__init__.py b/harborrl/deployment/__init__.py
new file mode 100644
index 000000000..cfae85518
--- /dev/null
+++ b/harborrl/deployment/__init__.py
@@ -0,0 +1 @@
+"""Deployment topology: machine roles, preflight and remote training targets."""
diff --git a/harborrl/deployment/controller.py b/harborrl/deployment/controller.py
new file mode 100644
index 000000000..0ee3700e3
--- /dev/null
+++ b/harborrl/deployment/controller.py
@@ -0,0 +1,207 @@
+"""Service-mode dispatch: the manager-side controller flow (P1 + P3).
+
+An independent CPU manager runs the same preflight graph, materializes the
+run on shared storage, starts the RolloutService control plane, launches the
+existing Slime job on the GPU target over SSH, and waits for the shared
+exit-code record. Rollout execution itself moves to the manager only through
+the service; training stays on the GPU.
+"""
+from __future__ import annotations
+
+import contextlib
+import json
+import os
+import signal
+import sys
+import threading
+from pathlib import Path
+
+from harborrl.config import ROOT
+from harborrl.deployment import remote
+from harborrl.deployment.schema import destination
+from harborrl.deployment.training_target import SshTrainingTarget
+from harborrl.platform.evidence import (
+ _materialize,
+ _source_audit,
+ effective_config,
+ new_run_root,
+ run_environment,
+)
+from harborrl.rollout.service import RolloutService, make_control_server
+from harborrl.trajectories.native import publish
+
+TOKEN_MODE = 0o600
+
+
+def check_gpu_control(deployment):
+ manager, gpu = deployment['manager'], deployment['gpu_target']
+ timeout = deployment['connectivity']['http_timeout_s']
+ result = remote.run_ssh(gpu['ssh'] or {'host': 'localhost'},
+ f"curl --noproxy '*' -fsS --max-time {timeout} " + remote.quote(manager['control_url'] + '/healthz'),
+ timeout=timeout + 15)
+ if result.returncode != 0:
+ raise RuntimeError('GPU cannot reach manager control service')
+ payload = json.loads(result.stdout)
+ if payload != {'ok': True, 'service': 'harborrl-rollout-service-v1'}:
+ raise RuntimeError('GPU reached an unexpected control service')
+ return {'gpu_to_controller': True, 'control_url': manager['control_url']}
+
+
+def _ensure_token(token_file):
+ """Load the run token, creating a fresh one on first use."""
+ path = Path(token_file)
+ if path.is_file():
+ token = path.read_text().strip()
+ if token:
+ return token
+ import secrets
+
+ token = secrets.token_urlsafe(32)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, TOKEN_MODE)
+ with os.fdopen(fd, "w") as stream:
+ stream.write(token + "\n")
+ return token
+
+
+def _rollout_environment(plan, deployment, environment):
+ """Environment overlay that points the GPU job at the manager service."""
+ manager = deployment["manager"]
+ workers = deployment["rollout"]["workers"]
+ overlay = dict(environment)
+ overlay.update({
+ "HARBORRL_ROLLOUT_MODE": "service",
+ "HARBORRL_ROLLOUT_SERVICE_URL": manager["control_url"],
+ "HARBORRL_ROLLOUT_SERVICE_TOKEN_FILE": manager['run_token_file'],
+ "TRAIN_PYTHON": deployment['gpu_target']['train_python'],
+ "HARBORRL_NATIVE_GATEWAY_HOST": manager["bind"],
+ "HARBORRL_NATIVE_GATEWAY_PORT": str(manager["gateway_port"]),
+ "HARBORRL_NATIVE_GATEWAY_URL": manager["gateway_url"],
+ "HARBORRL_NATIVE_RUNNER_WORKERS": json.dumps(
+ [destination(worker["ssh"]) for worker in workers]),
+ "HARBORRL_NATIVE_RUNNER_PYTHON": (plan['config']['harbor']['python']
+ if deployment['rollout']['provider'] == 'sandbox'
+ else workers[0]['runner_python']),
+ })
+ if deployment['rollout']['provider'] == 'sandbox':
+ if 'HARBORRL_NATIVE_SANDBOX' not in overlay:
+ raise ValueError('sandbox deployment requires sandbox training config')
+ transport = plan['config']['harbor'].get('runner_transport', 'ssh')
+ if (transport == 'local' and workers) or (transport == 'ssh' and not workers):
+ raise ValueError('sandbox runner transport and deployment workers disagree')
+ overlay['HARBORRL_NATIVE_RUNNER_TRANSPORT'] = transport
+ return overlay
+
+
+def _runtime_factory(deployment, environment, *, concurrency_limit, group_size, tokenizer=None):
+ """Build the per-run rollout runtime when the GPU job announces serving."""
+
+ def factory(run_id, router_url):
+ from harborrl.rollout.runtime import RolloutRuntime, RolloutRuntimeConfig
+
+ env = {**os.environ, **environment}
+ config = RolloutRuntimeConfig.from_environment(
+ env, endpoint=router_url, concurrency_limit=concurrency_limit,
+ group_size=group_size,
+ gateway_host=deployment["manager"]["bind"],
+ gateway_port=deployment["manager"]["gateway_port"],
+ gateway_url=deployment["manager"]["gateway_url"])
+ resolved = tokenizer
+ if resolved is None:
+ from transformers import AutoTokenizer
+
+ resolved = AutoTokenizer.from_pretrained(
+ env["HF_CKPT"], trust_remote_code=True)
+ return RolloutRuntime(config, resolved)
+
+ return factory
+
+
+def dispatch_service(plan, deployment, *, checks):
+ manager, gpu, storage = deployment["manager"], deployment["gpu_target"], deployment["storage"]
+ config = plan["config"]
+ root = new_run_root(storage["run_root"])
+ root.mkdir(parents=True, mode=0o700)
+ run_id = root.name
+
+ catalog_path, prompt_path = _materialize(plan, root)
+ source = _source_audit(root)
+ environment = run_environment(plan, root, catalog_path, prompt_path)
+ token = _ensure_token(manager["run_token_file"])
+ environment = _rollout_environment(plan, deployment, environment)
+
+ sampling = config["sampling"]
+ concurrency = sampling["group_size"] * sampling["groups_per_batch"]
+ service = RolloutService(
+ run_root=root,
+ runtime_factory=_runtime_factory(
+ deployment, environment,
+ concurrency_limit=concurrency, group_size=sampling["group_size"]),
+ token_file=manager["run_token_file"])
+ control = make_control_server(service, host=manager["bind"],
+ port=manager["control_port"])
+ control_thread = None
+ target = None
+ exit_code = 1
+ try:
+ service.register_run(run_id, {
+ "run_id": run_id, "deployment_source": plan["deployment"]["source"],
+ "source_commit": source["commit"],
+ "config_digest": plan["deployment"]["digest"],
+ "group_size": sampling["group_size"],
+ "workers": [worker["id"] for worker in deployment["rollout"]["workers"]],
+ }, token=token)
+ control_thread = threading.Thread(
+ target=control.serve_forever, name="harborrl-control-plane", daemon=True)
+ control_thread.start()
+ publish(root / 'control-readiness.json', check_gpu_control(deployment))
+
+ publish(root / "launch.json", {
+ "plan": plan, "run_dir": str(root), "source": source,
+ "materialized_catalog": str(catalog_path), "prompt_data": str(prompt_path),
+ "control_url": manager["control_url"]})
+ publish(root / "effective-config.json",
+ effective_config(plan, environment, dict(environment), source, checks))
+
+ target = SshTrainingTarget(
+ gpu["ssh"] or {"host": "localhost"}, source_dir=gpu["source_dir"], run_dir=root,
+ environment_file=gpu.get("environment_file"),
+ command_timeout=deployment["connectivity"]["command_timeout_s"] + 30,
+ ssh_timeout=deployment["connectivity"]["ssh_timeout_s"])
+ # The launcher executes inside the GPU checkout; manager-side absolute
+ # paths are rewritten so shared-storage artifacts keep one spelling.
+ target.write_launcher(
+ ["bash", str(ROOT / "harborrl/platform/slime_train.sh")], environment,
+ path_rewrites=[(str(manager["source_dir"]), str(gpu["source_dir"]))])
+ target.launch()
+
+ def forward_signal(signum, frame):
+ if target is not None:
+ target.cancel()
+
+ previous = {sig: signal.signal(sig, forward_signal)
+ for sig in (signal.SIGINT, signal.SIGTERM)}
+ try:
+ exit_code = target.wait()
+ finally:
+ for sig, handler in previous.items():
+ signal.signal(sig, handler)
+ except BaseException:
+ import traceback
+
+ traceback.print_exc()
+ if target is not None:
+ # Best-effort teardown; the run record keeps the failure evidence.
+ with contextlib.suppress(Exception):
+ target.cancel()
+ raise
+ finally:
+ with contextlib.suppress(Exception):
+ service.drain_run(run_id)
+ service.close()
+ if control_thread is not None:
+ control.shutdown()
+ control_thread.join(5)
+ control.server_close()
+ print(json.dumps({"run_dir": str(root), "exit_code": exit_code}), file=sys.stderr)
+ return exit_code
diff --git a/harborrl/deployment/preflight.py b/harborrl/deployment/preflight.py
new file mode 100644
index 000000000..94cb6fd6f
--- /dev/null
+++ b/harborrl/deployment/preflight.py
@@ -0,0 +1,340 @@
+"""Role-layered preflight graph shared by train, doctor and quickstart.
+
+Every check names the role and machine it ran against, the outcome, and a
+user-executable fix. The graph never raises: failures are data, and any
+failed mandatory check keeps a run from being created.
+"""
+from __future__ import annotations
+
+import json
+import socket
+import subprocess
+import time
+from pathlib import Path
+from urllib.parse import urlsplit
+
+from harborrl.deployment import remote
+from harborrl.deployment.schema import destination
+
+
+def _check(role, machine, name, ok, detail="", hint=None, duration_ms=None):
+ record = {"role": role, "machine": machine, "check": name, "ok": bool(ok),
+ "detail": str(detail)}
+ if hint:
+ record["hint"] = str(hint)
+ if duration_ms is not None:
+ record["duration_ms"] = round(duration_ms, 3)
+ return record
+
+
+def _timed(record_factory):
+ started = time.monotonic()
+ record = record_factory()
+ record["duration_ms"] = (time.monotonic() - started) * 1000
+ return record
+
+
+def _bindable(host, port):
+ """Probe with the same address reuse semantics as our HTTP servers."""
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
+ try:
+ server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ server.bind((host, port))
+ server.listen()
+ return True, "port is free"
+ except OSError as exc:
+ return False, f"cannot bind manager listener on {host}:{port}: {exc}"
+
+
+def _manager_checks(deployment):
+ manager, storage = deployment["manager"], deployment["storage"]
+ checks = []
+ run_root = Path(storage["run_root"])
+ exists = run_root.is_dir()
+ writable = exists and _touch_ok(run_root) if storage["manager_writable"] else exists
+ checks.append(_timed(lambda: _check(
+ "manager", manager["id"], "storage.run_root", exists and writable, str(run_root),
+ hint="create the shared run root and grant the manager write access")))
+
+ if manager["mode"] == "service":
+ token = Path(manager["run_token_file"])
+ token_ok = token.is_file()
+ detail = str(token)
+ if token_ok:
+ mode = token.stat().st_mode & 0o777
+ token_ok = mode & 0o077 == 0
+ detail = f"{token} mode {oct(mode)}"
+ checks.append(_timed(lambda: _check(
+ "manager", manager["id"], "manager.run_token_file", token_ok, detail,
+ hint="install the run-scoped token with chmod 600")))
+
+ for name, port in (("manager.control_port", manager["control_port"]),
+ ("manager.gateway_port", manager["gateway_port"])):
+ started = time.monotonic()
+ ok, detail = _bindable(manager["bind"], port)
+ checks.append(_check(
+ "manager", manager["id"], name, ok, detail,
+ hint=f"free the port or stop the conflicting listener on {manager['bind']}:{port}",
+ duration_ms=(time.monotonic() - started) * 1000))
+ return checks
+
+
+def _touch_ok(directory):
+ probe = directory / ".harborrl-preflight-probe"
+ try:
+ probe.write_text("write probe\n")
+ probe.unlink()
+ return True
+ except OSError:
+ return False
+
+
+def _local_runner():
+ def run(command, *, timeout=30):
+ try:
+ return subprocess.run(command, capture_output=True, text=True,
+ timeout=timeout, check=False, shell=isinstance(command, str))
+ except (subprocess.TimeoutExpired, OSError) as exc:
+ return subprocess.CompletedProcess(command, 255, "", f"{type(exc).__name__}: {exc}")
+
+ def test(expression):
+ result = run(f"test {expression} && echo ok")
+ return result.returncode == 0 and result.stdout.strip() == "ok", result
+
+ return run, test
+
+
+def _gpu_checks(plan, deployment):
+ gpu, connectivity = deployment["gpu_target"], deployment["connectivity"]
+ config = plan["config"]
+ machine = gpu["id"]
+ checks = []
+ if gpu["transport"] == "ssh":
+ spec = gpu["ssh"]
+ started = time.monotonic()
+ probe = remote.run_ssh(spec, "true", timeout=connectivity["ssh_timeout_s"] + 5,
+ connect_timeout=connectivity["ssh_timeout_s"])
+ checks.append(_check(
+ "gpu_target", machine, "gpu_target.ssh", probe.returncode == 0,
+ probe.stderr.strip()[-300:] or "non-interactive ssh ok",
+ hint="the SSH config alias, agent or identity file must allow non-interactive login",
+ duration_ms=(time.monotonic() - started) * 1000))
+ if probe.returncode != 0:
+ checks.append(_check(
+ "gpu_target", machine, "gpu_target.further_probes", True,
+ "skipped: fix gpu_target.ssh first, then rerun for path, model and GPU checks"))
+ return checks
+
+ def execute(command):
+ return remote.run_ssh(spec, command, timeout=connectivity["command_timeout_s"],
+ connect_timeout=connectivity["ssh_timeout_s"])
+
+ def shell_test(expression):
+ ok, result = remote.test_remote(spec, expression,
+ timeout=connectivity["command_timeout_s"],
+ connect_timeout=connectivity["ssh_timeout_s"])
+ return ok, result
+ else:
+ run, test = _local_runner()
+
+ def execute(command):
+ return run(command, timeout=connectivity["command_timeout_s"])
+
+ def shell_test(expression):
+ return test(expression)
+
+ def path_check(name, expression, target, hint):
+ started = time.monotonic()
+ ok, _result = shell_test(expression)
+ checks.append(_check("gpu_target", machine, name, ok, target,
+ hint=hint, duration_ms=(time.monotonic() - started) * 1000))
+
+ path_check("gpu_target.source_dir", f"-d {remote.quote(gpu['source_dir'])}", gpu["source_dir"],
+ "mount or clone the HarborRL checkout at gpu_target.source_dir")
+ path_check("gpu_target.train_python", f"-x {remote.quote(gpu['train_python'])}", gpu["train_python"],
+ "provision the training interpreter at gpu_target.train_python")
+ if gpu.get("environment_file"):
+ path_check("gpu_target.environment_file",
+ f"-f {remote.quote(gpu['environment_file'])}", gpu["environment_file"],
+ "provide the controlled environment variable map")
+ for key in ("checkpoint", "reference"):
+ target = config["model"][key]
+ path_check(f"model.{key}", f"-d {remote.quote(target)}", target,
+ "the model checkpoint must exist in GPU-machine path semantics")
+
+ started = time.monotonic()
+ gpu_list = execute("nvidia-smi --query-gpu=index --format=csv,noheader")
+ visible = len([line for line in gpu_list.stdout.splitlines() if line.strip()])
+ checks.append(_check(
+ "gpu_target", machine, "gpu_target.expected_gpus",
+ gpu_list.returncode == 0 and visible >= gpu["expected_gpus"],
+ f"{visible} visible GPUs (returncode {gpu_list.returncode})",
+ hint="check CUDA visibility and the GPU allocation before training",
+ duration_ms=(time.monotonic() - started) * 1000))
+ return checks
+
+
+def _worker_checks(plan, deployment):
+ workers, connectivity = deployment["rollout"]["workers"], deployment["connectivity"]
+ expected_harbor = plan["config"]["harbor"]["version"]
+ checks = []
+ for worker in workers:
+ spec, machine = worker["ssh"], worker["id"]
+ started = time.monotonic()
+ probe = remote.run_ssh(spec, "true", timeout=connectivity["ssh_timeout_s"] + 5,
+ connect_timeout=connectivity["ssh_timeout_s"])
+ checks.append(_check(
+ "rollout_worker", machine, "worker.ssh", probe.returncode == 0,
+ probe.stderr.strip()[-300:] or "non-interactive ssh ok",
+ hint=f"SSH access to {destination(spec)} must work without a password prompt",
+ duration_ms=(time.monotonic() - started) * 1000))
+ if probe.returncode != 0:
+ continue
+
+ def worker_test(spec, machine, name, expression, target, hint):
+ started = time.monotonic()
+ ok, _result = remote.test_remote(spec, expression,
+ timeout=connectivity["command_timeout_s"],
+ connect_timeout=connectivity["ssh_timeout_s"])
+ checks.append(_check("rollout_worker", machine, name, ok, target,
+ hint=hint, duration_ms=(time.monotonic() - started) * 1000))
+
+ worker_test(spec, machine, "worker.source_dir",
+ f"-d {remote.quote(worker['source_dir'])}",
+ worker["source_dir"], "mount the HarborRL checkout on the worker")
+ worker_test(spec, machine, "worker.runner_python",
+ f"-f {remote.quote(worker['runner_python'])}",
+ worker["runner_python"], "provision the runner interpreter on the worker")
+ for index, task_root in enumerate(worker["task_roots"]):
+ worker_test(spec, machine, f"worker.task_roots[{index}]",
+ f"-d {remote.quote(task_root)}",
+ task_root, "mount the task tree on the worker")
+
+ started = time.monotonic()
+ probe_command = (f"cd {remote.quote(worker['source_dir'])} && "
+ f"{remote.quote(worker['runner_python'])} -m "
+ "harborrl.rollout.harbor_job.runner --probe")
+ result = remote.run_ssh(spec, probe_command, timeout=connectivity["command_timeout_s"],
+ connect_timeout=connectivity["ssh_timeout_s"])
+ runtime_ok = harbor_ok = False
+ detail = result.stderr.strip()[-300:] or result.stdout.strip()[-300:]
+ try:
+ payload = json.loads(result.stdout)
+ parts = payload.get("python", "").split(".")[:2]
+ runtime_ok = (result.returncode == 0
+ and len(parts) == 2 and all(p.isdigit() for p in parts)
+ and tuple(map(int, parts)) >= (3, 12))
+ harbor_ok = payload.get("harbor_version") == expected_harbor
+ if result.returncode == 0:
+ detail = f"python {payload.get('python')}, harbor {payload.get('harbor_version')}"
+ except (ValueError, AttributeError, TypeError):
+ pass
+ checks.append(_check(
+ "rollout_worker", machine, "worker.runner_runtime", runtime_ok, detail,
+ hint="runner Python must be >= 3.12 with the pinned Harbor installation",
+ duration_ms=(time.monotonic() - started) * 1000))
+ checks.append(_check(
+ "rollout_worker", machine, "worker.harbor_version", harbor_ok, detail,
+ hint=f"pin Harbor {expected_harbor} in the runner environment",
+ duration_ms=(time.monotonic() - started) * 1000))
+
+ started = time.monotonic()
+ docker = remote.run_ssh(spec, "docker info --format '{{.ServerVersion}}'",
+ timeout=connectivity["command_timeout_s"],
+ connect_timeout=connectivity["ssh_timeout_s"])
+ checks.append(_check(
+ "rollout_worker", machine, "worker.docker", docker.returncode == 0,
+ docker.stdout.strip()[:120] or docker.stderr.strip()[-200:],
+ hint="start the Docker daemon; Harbor trials require container execution",
+ duration_ms=(time.monotonic() - started) * 1000))
+ return checks
+
+
+def _control_loop_checks(deployment):
+ manager, connectivity = deployment["manager"], deployment["connectivity"]
+ checks = []
+ if manager["mode"] != "service":
+ return checks
+ dials = (("connectivity.gpu_to_controller", manager["control_url"], connectivity["gpu_to_controller"]),
+ ("connectivity.worker_to_gateway", manager["gateway_url"], connectivity["worker_to_gateway"]))
+ for name, url, enabled in dials:
+ if not enabled:
+ continue
+ parts = urlsplit(url)
+ started = time.monotonic()
+ try:
+ socket.getaddrinfo(parts.hostname, parts.port)
+ ok, detail = True, "address resolves from the manager"
+ except socket.gaierror as exc:
+ ok, detail = False, f"{exc}"
+ checks.append(_check("control_loop", manager["id"], name, ok, detail,
+ hint="fix DNS or firewall so the address resolves for every role",
+ duration_ms=(time.monotonic() - started) * 1000))
+ started = time.monotonic()
+ serving = False
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client:
+ client.settimeout(min(connectivity["http_timeout_s"], 5))
+ serving = client.connect_ex((parts.hostname, parts.port)) == 0
+ except OSError:
+ serving = False # unreachable now; the post-start dial retries
+ checks.append(_check(
+ "control_loop", manager["id"], name + ".dial", True,
+ "listener already serving" if serving
+ else "no listener yet; the live dial reruns after components start",
+ duration_ms=(time.monotonic() - started) * 1000))
+ if connectivity["manager_to_gpu_inference"]:
+ checks.append(_check(
+ "control_loop", manager["id"], "connectivity.manager_to_gpu_inference", True,
+ "deferred: the GPU job publishes its inference endpoint after serving is healthy"))
+ return checks
+
+
+def preflight(plan, deployment, *, live=True):
+ """Run every deployment layer; remote probes are skipped when live=False."""
+ if not live:
+ return [_check("deployment", deployment["manager"]["id"], "deployment.config",
+ True, "dry-run: parsed and validated, remote probes skipped")]
+ checks = []
+ checks.extend(_manager_checks(deployment))
+ checks.extend(_gpu_checks(plan, deployment))
+ if deployment["rollout"]["provider"] == "ssh_docker":
+ checks.extend(_worker_checks(plan, deployment))
+ checks.extend(_control_loop_checks(deployment))
+ return checks
+
+
+def readiness_checks(plan, deployment, *, from_specs=()):
+ """Post-start dials: cross-machine health after components are listening.
+
+ ``from_specs`` maps a role to the ssh spec that should perform the dial;
+ callers pass the GPU target and rollout workers of a live deployment.
+ """
+ manager, connectivity = deployment["manager"], deployment["connectivity"]
+ checks = []
+ dials = (("gpu_target", "gpu_to_controller.ready", manager["control_url"],
+ connectivity["gpu_to_controller"]),
+ ("rollout_worker", "worker_to_gateway.ready", manager["gateway_url"],
+ connectivity["worker_to_gateway"]))
+ specs = dict(from_specs)
+ for role, name, url, enabled in dials:
+ if not enabled:
+ continue
+ spec = specs.get(role)
+ if spec is None:
+ checks.append(_check("control_loop", manager["id"], name, True,
+ "deferred: no machine mapped for this dial"))
+ continue
+ path = "/healthz" if "control" in name else "/readyz"
+ command = (f"python3 -c "
+ f"\"import urllib.request;urllib.request.urlopen('{url}{path}',"
+ f"timeout={connectivity['http_timeout_s']})\"")
+ started = time.monotonic()
+ result = remote.run_ssh(spec, command, timeout=connectivity["command_timeout_s"],
+ connect_timeout=connectivity["ssh_timeout_s"])
+ checks.append(_check("control_loop", destination(spec), name,
+ result.returncode == 0,
+ result.stderr.strip()[-300:] or "health dial ok",
+ hint=f"open the route from {destination(spec)} to {url}{path}",
+ duration_ms=(time.monotonic() - started) * 1000))
+ return checks
diff --git a/harborrl/deployment/remote.py b/harborrl/deployment/remote.py
new file mode 100644
index 000000000..e9ddd276c
--- /dev/null
+++ b/harborrl/deployment/remote.py
@@ -0,0 +1,55 @@
+"""Non-interactive SSH transport for deployment preflight and launch.
+
+Every invocation runs BatchMode: a password prompt is a failed check, never an
+interactive hang. User, port and identity are inherited from the invoking
+user's SSH config unless the deployment spec narrows them.
+"""
+from __future__ import annotations
+
+import shlex
+import subprocess
+
+SSH_OPTIONS = ("BatchMode=yes", "ClearAllForwardings=yes", "StrictHostKeyChecking=accept-new")
+
+
+def ssh_command(spec, remote_command, *, connect_timeout=15):
+ """Assemble one non-interactive ssh argv for a normalized spec."""
+ argv = ["ssh"]
+ for option in SSH_OPTIONS:
+ argv += ["-o", option]
+ argv += ["-o", f"ConnectTimeout={int(connect_timeout)}"]
+ if spec.get("port"):
+ argv += ["-p", str(spec["port"])]
+ if spec.get("identity"):
+ argv += ["-i", str(spec["identity"])]
+ from harborrl.deployment.schema import destination
+
+ argv += [destination(spec), remote_command]
+ return argv
+
+
+def run_ssh(spec, command, *, timeout=30, connect_timeout=15):
+ """Run one remote command; transport failures surface as returncode 255."""
+ argv = ssh_command(spec, command, connect_timeout=connect_timeout)
+ try:
+ return subprocess.run(argv, capture_output=True, text=True,
+ timeout=timeout, check=False)
+ except (subprocess.TimeoutExpired, OSError) as exc:
+ return subprocess.CompletedProcess(argv, 255, "", f"{type(exc).__name__}: {exc}")
+
+
+def test_remote(spec, expression, *, timeout=30, connect_timeout=15):
+ """Evaluate a shell test expression on the target; True when it passes."""
+ result = run_ssh(spec, f"test {expression} && echo ok", timeout=timeout,
+ connect_timeout=connect_timeout)
+ return result.returncode == 0 and result.stdout.strip() == "ok", result
+
+
+def remote_read(spec, command, *, timeout=30, connect_timeout=15):
+ """Return (returncode, stdout, stderr) for a remote command."""
+ result = run_ssh(spec, command, timeout=timeout, connect_timeout=connect_timeout)
+ return result.returncode, result.stdout, result.stderr
+
+
+def quote(value):
+ return shlex.quote(str(value))
diff --git a/harborrl/deployment/schema.py b/harborrl/deployment/schema.py
new file mode 100644
index 000000000..3cc7bf7c8
--- /dev/null
+++ b/harborrl/deployment/schema.py
@@ -0,0 +1,330 @@
+"""Deployment schema v1: machine roles and access, never training semantics.
+
+A deployment config describes WHERE the manager, GPU trainer and rollout
+workers live and HOW they reach each other. It deliberately excludes every
+training-side field (learning rate, sampling, task catalog) and never stores
+secrets: SSH targets must resolve through the invoking user's SSH config,
+agent or a restricted identity file.
+"""
+from __future__ import annotations
+
+import re
+import sys
+from pathlib import Path
+from urllib.parse import urlsplit
+
+import yaml
+
+from harborrl.config import ROOT
+
+DEPLOYMENT_SCHEMA_VERSION = 1
+DEPLOYMENT_SECTIONS = {"manager", "gpu_target", "storage", "rollout", "connectivity"}
+
+SLUG = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]*")
+LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1", "0.0.0.0", "[::1]"}
+
+MANAGER_FIELDS = {"id", "mode", "bind", "control_port", "control_url", "gateway_port",
+ "gateway_url", "run_token_file", "source_dir", "controller_python"}
+SSH_FIELDS = {"host", "user", "port", "identity"}
+GPU_FIELDS = {"id", "transport", "ssh", "source_dir", "train_python", "environment_file", "expected_gpus"}
+STORAGE_FIELDS = {"mode", "run_root", "manager_writable", "gpu_writable", "worker_writable"}
+WORKER_FIELDS = {"id", "ssh", "source_dir", "runner_python", "docker", "task_roots"}
+ROLLOUT_FIELDS = {"provider", "workers"}
+CONNECTIVITY_FIELDS = {"command_timeout_s", "ssh_timeout_s", "http_timeout_s",
+ "gpu_to_controller", "worker_to_gateway", "manager_to_gpu_inference"}
+TRANSPORTS = ("local", "ssh")
+ROLLOUT_PROVIDERS = ("ssh_docker", "embedded", "sandbox")
+STORAGE_MODES = ("shared_fs",)
+
+
+class DeploymentError(ValueError):
+ """Raised for any malformed or unsafe deployment configuration."""
+
+
+def _slug(value, label):
+ if not isinstance(value, str) or not SLUG.fullmatch(value):
+ raise DeploymentError(f"{label} must be a short machine identifier")
+ return value
+
+
+def _positive_int(value, label):
+ if type(value) is not int or value <= 0 or type(value) is bool:
+ raise DeploymentError(f"{label} must be a positive integer")
+ return value
+
+
+def _nonempty_str(value, label):
+ if not isinstance(value, str) or not value.strip():
+ raise DeploymentError(f"{label} must be a nonempty string")
+ return value
+
+
+def _bool(value, label):
+ if type(value) is not bool:
+ raise DeploymentError(f"{label} must be a boolean")
+ return value
+
+
+def _absolute_path(value, label):
+ value = _nonempty_str(value, label)
+ if not Path(value).is_absolute():
+ raise DeploymentError(f"{label} must be an absolute path in target-machine semantics")
+ return value
+
+
+def _plain_origin(url, label, *, port=None, allow_loopback=False):
+ parts = urlsplit(_nonempty_str(url, label))
+ if (parts.scheme not in ("http", "https") or not parts.hostname
+ or parts.username or parts.password or parts.query or parts.fragment
+ or parts.path not in ("", "/")
+ or (port is not None and parts.port != port)):
+ raise DeploymentError(f"{label} must be a plain http(s) origin matching its declared port")
+ if not allow_loopback and parts.hostname.lower() in LOOPBACK_HOSTS:
+ raise DeploymentError(f"{label} must be reachable from other machines; loopback is forbidden")
+ return f"{parts.scheme}://{parts.netloc}".rstrip("/")
+
+
+def _ssh_spec(value, label, *, require_host=True):
+ if value is None:
+ value = {}
+ if not isinstance(value, dict) or set(value) - SSH_FIELDS:
+ raise DeploymentError(
+ f"{label} supports only {sorted(SSH_FIELDS)}; passwords and private keys are never stored")
+ host = value.get("host")
+ if host is not None:
+ host = _nonempty_str(host, f"{label}.host")
+ if host.startswith("-") or host.count("@") > 1:
+ raise DeploymentError(f"{label}.host must be an SSH destination, not a flag")
+ if require_host and not host:
+ raise DeploymentError(f"{label}.host is required")
+ user = value.get("user")
+ if user is not None:
+ user = _nonempty_str(user, f"{label}.user")
+ if user.startswith("-"):
+ raise DeploymentError(f"{label}.user must not look like a flag")
+ port = value.get("port")
+ if port is not None:
+ port = _positive_int(port, f"{label}.port")
+ identity = value.get("identity")
+ if identity is not None:
+ identity = _absolute_path(identity, f"{label}.identity")
+ spec = {"host": host}
+ if user:
+ spec["user"] = user
+ if port:
+ spec["port"] = port
+ if identity:
+ spec["identity"] = identity
+ return spec
+
+
+def destination(spec):
+ """Render an ssh destination from a normalized spec (user@host)."""
+ return f"{spec['user']}@{spec['host']}" if spec.get("user") else spec["host"]
+
+
+def _manager(config):
+ if not isinstance(config, dict) or set(config) != MANAGER_FIELDS:
+ raise DeploymentError(f"manager requires exactly {sorted(MANAGER_FIELDS)}")
+ mode = config["mode"]
+ if mode not in ("embedded", "service"):
+ raise DeploymentError("manager.mode must be embedded or service")
+ service = mode == "service"
+ control_port = _positive_int(config["control_port"], "manager.control_port")
+ gateway_port = _positive_int(config["gateway_port"], "manager.gateway_port")
+ if control_port == gateway_port:
+ raise DeploymentError("manager.control_port and manager.gateway_port must differ")
+ if not 0 < control_port < 65536 or not 0 < gateway_port < 65536:
+ raise DeploymentError("manager ports must be in 1..65535")
+ control_url = _plain_origin(config["control_url"], "manager.control_url",
+ port=control_port, allow_loopback=not service)
+ gateway_url = _plain_origin(config["gateway_url"], "manager.gateway_url",
+ port=gateway_port, allow_loopback=not service)
+ return {
+ "id": _slug(config["id"], "manager.id"), "mode": mode,
+ "bind": _nonempty_str(config["bind"], "manager.bind"),
+ "control_port": control_port, "control_url": control_url,
+ "gateway_port": gateway_port, "gateway_url": gateway_url,
+ "run_token_file": _absolute_path(config["run_token_file"], "manager.run_token_file"),
+ "source_dir": _absolute_path(config["source_dir"], "manager.source_dir"),
+ "controller_python": _absolute_path(config["controller_python"], "manager.controller_python"),
+ }
+
+
+def _gpu_target(config):
+ if not isinstance(config, dict) or set(config) != GPU_FIELDS:
+ raise DeploymentError(f"gpu_target requires exactly {sorted(GPU_FIELDS)}")
+ transport = config["transport"]
+ if transport not in TRANSPORTS:
+ raise DeploymentError(f"gpu_target.transport must be one of {TRANSPORTS}")
+ ssh = config["ssh"]
+ if transport == "ssh":
+ ssh = _ssh_spec(ssh, "gpu_target.ssh")
+ elif ssh is not None:
+ raise DeploymentError("gpu_target.ssh must be null for local transport")
+ target = {
+ "id": _slug(config["id"], "gpu_target.id"), "transport": transport, "ssh": ssh,
+ "source_dir": _absolute_path(config["source_dir"], "gpu_target.source_dir"),
+ "train_python": _absolute_path(config["train_python"], "gpu_target.train_python"),
+ "expected_gpus": _positive_int(config["expected_gpus"], "gpu_target.expected_gpus"),
+ }
+ environment_file = config["environment_file"]
+ if environment_file is not None:
+ target["environment_file"] = _absolute_path(environment_file, "gpu_target.environment_file")
+ return target
+
+
+def _storage(config):
+ if not isinstance(config, dict) or set(config) != STORAGE_FIELDS:
+ raise DeploymentError(f"storage requires exactly {sorted(STORAGE_FIELDS)}")
+ if config["mode"] not in STORAGE_MODES:
+ raise DeploymentError(f"storage.mode must be one of {STORAGE_MODES}")
+ return {
+ "mode": config["mode"],
+ "run_root": _absolute_path(config["run_root"], "storage.run_root"),
+ "manager_writable": _bool(config["manager_writable"], "storage.manager_writable"),
+ "gpu_writable": _bool(config["gpu_writable"], "storage.gpu_writable"),
+ "worker_writable": _bool(config["worker_writable"], "storage.worker_writable"),
+ }
+
+
+def _rollout(config):
+ if not isinstance(config, dict) or set(config) != ROLLOUT_FIELDS:
+ raise DeploymentError(f"rollout requires exactly {sorted(ROLLOUT_FIELDS)}")
+ if config["provider"] not in ROLLOUT_PROVIDERS:
+ raise DeploymentError(f"rollout.provider must be one of {ROLLOUT_PROVIDERS}")
+ workers = config["workers"]
+ # ssh_docker needs explicit machines; the embedded provider falls back to
+ # the training config's worker list, so it may start empty.
+ if (not isinstance(workers, list)
+ or (not workers and config["provider"] not in ("embedded", "sandbox"))):
+ raise DeploymentError("rollout.workers must be a nonempty list")
+ normalized, seen = [], set()
+ for worker in workers:
+ if not isinstance(worker, dict) or set(worker) != WORKER_FIELDS:
+ raise DeploymentError(f"rollout worker requires exactly {sorted(WORKER_FIELDS)}")
+ worker_id = _slug(worker["id"], "rollout worker id")
+ if worker_id in seen:
+ raise DeploymentError(f"duplicate rollout worker id: {worker_id}")
+ seen.add(worker_id)
+ task_roots = worker["task_roots"]
+ if (not isinstance(task_roots, list) or not task_roots
+ or any(not isinstance(root, str) or not root.strip() for root in task_roots)):
+ raise DeploymentError(f"worker {worker_id} requires nonempty task_roots")
+ normalized.append({
+ "id": worker_id,
+ "ssh": _ssh_spec(worker["ssh"], f"rollout worker {worker_id} ssh"),
+ "source_dir": _absolute_path(worker["source_dir"], f"worker {worker_id} source_dir"),
+ "runner_python": _absolute_path(worker["runner_python"], f"worker {worker_id} runner_python"),
+ "docker": _bool(worker["docker"], f"worker {worker_id} docker"),
+ "task_roots": [_absolute_path(root, f"worker {worker_id} task_root") for root in task_roots],
+ })
+ return {"provider": config["provider"], "workers": normalized}
+
+
+def _connectivity(config):
+ if not isinstance(config, dict) or set(config) != CONNECTIVITY_FIELDS:
+ raise DeploymentError(f"connectivity requires exactly {sorted(CONNECTIVITY_FIELDS)}")
+ return {
+ "command_timeout_s": _positive_int(config["command_timeout_s"], "connectivity.command_timeout_s"),
+ "ssh_timeout_s": _positive_int(config["ssh_timeout_s"], "connectivity.ssh_timeout_s"),
+ "http_timeout_s": _positive_int(config["http_timeout_s"], "connectivity.http_timeout_s"),
+ "gpu_to_controller": _bool(config["gpu_to_controller"], "connectivity.gpu_to_controller"),
+ "worker_to_gateway": _bool(config["worker_to_gateway"], "connectivity.worker_to_gateway"),
+ "manager_to_gpu_inference": _bool(config["manager_to_gpu_inference"], "connectivity.manager_to_gpu_inference"),
+ }
+
+
+def validate(config):
+ """Strictly validate and normalize a deployment mapping."""
+ if not isinstance(config, dict):
+ raise DeploymentError("deployment config must be a mapping")
+ if set(config) != DEPLOYMENT_SECTIONS | {"schema_version"}:
+ raise DeploymentError(
+ "deployment config requires exactly schema_version and "
+ + ", ".join(sorted(DEPLOYMENT_SECTIONS)))
+ if config["schema_version"] != DEPLOYMENT_SCHEMA_VERSION or type(config["schema_version"]) is not int:
+ raise DeploymentError(f"deployment schema_version must be {DEPLOYMENT_SCHEMA_VERSION}")
+ return {
+ "schema_version": DEPLOYMENT_SCHEMA_VERSION,
+ "manager": _manager(config["manager"]),
+ "gpu_target": _gpu_target(config["gpu_target"]),
+ "storage": _storage(config["storage"]),
+ "rollout": _rollout(config["rollout"]),
+ "connectivity": _connectivity(config["connectivity"]),
+ }
+
+
+def embedded_local(*, run_root=None, workers=(), expected_gpus=1):
+ """Default same-machine deployment; behavior matches the legacy pipeline."""
+ return validate({
+ "schema_version": 1,
+ "manager": {"id": "embedded-local", "mode": "embedded", "bind": "127.0.0.1",
+ "control_port": 49200, "control_url": "http://127.0.0.1:49200",
+ "gateway_port": 49184, "gateway_url": "http://127.0.0.1:49184",
+ "run_token_file": str(ROOT / "runs" / "run-token"),
+ "source_dir": str(ROOT), "controller_python": sys.executable},
+ "gpu_target": {"id": "local", "transport": "local", "ssh": None,
+ "source_dir": str(ROOT), "train_python": sys.executable,
+ "environment_file": None, "expected_gpus": expected_gpus},
+ "storage": {"mode": "shared_fs", "run_root": run_root or str(ROOT / "runs"),
+ "manager_writable": True, "gpu_writable": True, "worker_writable": True},
+ "rollout": {"provider": "embedded", "workers": [
+ {"id": f"worker-{index}", "ssh": {"host": worker}, "source_dir": str(ROOT),
+ "runner_python": sys.executable, "docker": True, "task_roots": [str(ROOT)]}
+ for index, worker in enumerate(workers)]},
+ "connectivity": {"command_timeout_s": 30, "ssh_timeout_s": 15, "http_timeout_s": 10,
+ "gpu_to_controller": True, "worker_to_gateway": True,
+ "manager_to_gpu_inference": True},
+ })
+
+
+DEFAULT_FILENAME = "deployment.yaml"
+
+
+def load_deployment(path=None, *, default_directory="."):
+ """Load an explicit, default-discovered or embedded-local deployment config.
+
+ Returns ``(config, source)`` where source describes where the config came
+ from; absent any file the embedded-local topology keeps legacy behavior.
+ """
+ if path is not None:
+ source = str(Path(path).expanduser())
+ candidates = [source]
+ else:
+ candidates = [str(Path(default_directory) / DEFAULT_FILENAME), DEFAULT_FILENAME]
+ source = candidates[0]
+ for candidate in candidates:
+ file = Path(candidate)
+ if not file.is_file():
+ continue
+ try:
+ text = file.read_text()
+ except OSError as exc:
+ raise DeploymentError(f"cannot read deployment config {candidate}: {exc}") from exc
+ try:
+ raw = yaml.safe_load(text)
+ except yaml.YAMLError as exc:
+ raise DeploymentError(f"deployment YAML {candidate}: {exc}") from exc
+ return validate(raw), str(file.resolve())
+ if path is not None:
+ raise DeploymentError(f"deployment config not found: {path}")
+ return embedded_local(), "embedded-local"
+
+
+def summary(config, source):
+ """Plan-embedded deployment digest; the full config travels with it."""
+ from harborrl.trajectories.native import digest
+ return {
+ "schema_version": DEPLOYMENT_SCHEMA_VERSION,
+ "source": source,
+ "digest": digest(config),
+ "mode": config["manager"]["mode"],
+ "manager_id": config["manager"]["id"],
+ "control_url": config["manager"]["control_url"],
+ "gateway_url": config["manager"]["gateway_url"],
+ "gpu": {"id": config["gpu_target"]["id"], "transport": config["gpu_target"]["transport"]},
+ "worker_ids": [worker["id"] for worker in config["rollout"]["workers"]],
+ "run_root": config["storage"]["run_root"],
+ "config": config,
+ }
diff --git a/harborrl/deployment/training_target.py b/harborrl/deployment/training_target.py
new file mode 100644
index 000000000..4de5d2468
--- /dev/null
+++ b/harborrl/deployment/training_target.py
@@ -0,0 +1,217 @@
+"""TrainingTarget (P1): launch, query and cancel one Slime training job.
+
+``local`` keeps the legacy in-process launcher byte-for-byte. ``ssh`` runs the
+same launcher script on the GPU machine through shared storage: the run
+directory is written by the manager, executed by the GPU target, and polled
+without holding an interactive session open.
+"""
+from __future__ import annotations
+
+import os
+import shlex
+import signal
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+from harborrl.deployment import remote
+from harborrl.deployment.schema import destination
+
+LAUNCHER_NAME = "gpu-launcher.sh"
+EXIT_CODE_NAME = "exit-code"
+PID_NAME = "launcher.pid"
+STDOUT_NAME = "train-stdout.log"
+
+
+class TrainingTargetError(RuntimeError):
+ pass
+
+
+class LocalTrainingTarget:
+ """Legacy target: the Slime job runs as a child of this process."""
+
+ def __init__(self, command, *, cwd, env):
+ self.command = [str(part) for part in command]
+ self.cwd = str(cwd)
+ self.env = env
+ self.child = None
+
+ def launch(self):
+ self.child = subprocess.Popen(self.command, cwd=self.cwd, env=self.env,
+ start_new_session=True)
+ return self.child.pid
+
+ def wait(self):
+ if self.child is None:
+ raise TrainingTargetError("local target was never launched")
+
+ def forward_signal(signum, frame):
+ if self.child.poll() is None:
+ try:
+ os.killpg(os.getpgid(self.child.pid), signum)
+ except ProcessLookupError:
+ pass
+
+ previous = {sig: signal.signal(sig, forward_signal)
+ for sig in (signal.SIGINT, signal.SIGTERM)}
+ try:
+ return self.child.wait()
+ finally:
+ for sig, handler in previous.items():
+ signal.signal(sig, handler)
+
+ def query(self):
+ if self.child is None:
+ return {"state": "not-launched"}
+ return {"state": "running" if self.child.poll() is None else "exited",
+ "exit_code": self.child.returncode}
+
+ def cancel(self):
+ if self.child is None or self.child.poll() is not None:
+ return False
+ try:
+ os.killpg(os.getpgid(self.child.pid), signal.SIGINT)
+ except (ProcessLookupError, PermissionError):
+ self.child.terminate()
+ return True
+
+
+class SshTrainingTarget:
+ """Remote target over shared storage; the launcher script is the contract."""
+
+ def __init__(self, spec, *, source_dir, run_dir, environment_file=None,
+ command_timeout=60, ssh_timeout=15, poll_interval=2.0):
+ self.spec = dict(spec)
+ self.source_dir = str(source_dir)
+ self.run_dir = Path(run_dir).resolve()
+ self.environment_file = environment_file
+ self.command_timeout = command_timeout
+ self.ssh_timeout = ssh_timeout
+ self.poll_interval = poll_interval
+ self._launched = False
+
+ def write_launcher(self, command, environment, *, path_rewrites=()):
+ """Materialize the launcher; env values and paths are quoted, never interpolated."""
+ command = [str(part) for part in command]
+ for manager_path, target_path in path_rewrites:
+ command = [str(part).replace(str(manager_path), str(target_path), 1)
+ if str(part).startswith(str(manager_path)) else part for part in command]
+ lines = ["#!/usr/bin/env bash",
+ "# Generated by the HarborRL manager; executed on the GPU target.",
+ "set -uo pipefail",
+ f"cd {shlex.quote(self.source_dir)}",
+ f"export RUN_DIR={shlex.quote(str(self.run_dir))}"]
+ if self.environment_file:
+ # A controlled JSON mapping only; never an arbitrary remote shell.
+ quoted_file = shlex.quote(str(self.environment_file))
+ lines.append(
+ "eval \"$(HARBORRL_ENV_FILE=" + quoted_file + " python3 - <<'HARBORRL_ENV_JSON'\n"
+ "import json, os, shlex\n"
+ "with open(os.environ['HARBORRL_ENV_FILE']) as stream:\n"
+ " for key, value in sorted(json.load(stream).items()):\n"
+ " print(f'export {key}={shlex.quote(str(value))}')\n"
+ "HARBORRL_ENV_JSON\n"
+ ")\"")
+ for key in sorted(environment):
+ lines.append(f"export {key}={shlex.quote(str(environment[key]))}")
+ lines.append("status=0")
+ # The command is a full argv (its first word is already the
+ # interpreter); execute it as-is and capture the real exit code.
+ lines.append(" ".join(shlex.quote(part) for part in command)
+ + " || status=$?")
+ lines.append(f"printf '%s\\n' \"$status\" > {shlex.quote(str(self.run_dir / EXIT_CODE_NAME))}")
+ lines.append("exit \"$status\"")
+ launcher = self.run_dir / LAUNCHER_NAME
+ launcher.write_text("\n".join(lines) + "\n")
+ launcher.chmod(0o755)
+ return launcher
+
+ def launch(self):
+ if self._launched:
+ raise TrainingTargetError("ssh target was already launched")
+ # The background job records its own session-leader pid: setsid execs
+ # the launcher, so the recorded pid heads the cancellable process group.
+ bootstrap = (f"cd {shlex.quote(str(self.run_dir))} && "
+ f"(setsid nohup bash {LAUNCHER_NAME} > {STDOUT_NAME} 2>&1"
+ f" < /dev/null & echo $! > {PID_NAME})")
+ result = remote.run_ssh(self.spec, bootstrap, timeout=self.command_timeout,
+ connect_timeout=self.ssh_timeout)
+ if result.returncode != 0:
+ raise TrainingTargetError(
+ f"remote launch failed on {destination(self.spec)}: "
+ f"{result.stderr.strip()[-500:]}")
+ self._launched = True
+ return self.run_dir / PID_NAME
+
+ def _pid(self):
+ try:
+ return int((self.run_dir / PID_NAME).read_text().strip())
+ except (OSError, ValueError):
+ return None
+
+ def exit_code(self):
+ try:
+ return int((self.run_dir / EXIT_CODE_NAME).read_text().strip())
+ except (OSError, ValueError):
+ return None
+
+ def alive(self):
+ pid = self._pid()
+ if pid is None:
+ return False
+ result = remote.run_ssh(self.spec, f"kill -0 {pid} 2>/dev/null && echo ok",
+ timeout=self.command_timeout, connect_timeout=self.ssh_timeout)
+ return result.returncode == 0 and result.stdout.strip() == "ok"
+
+ def wait(self, *, timeout=None):
+ """Poll the shared exit-code record; never resubmit or restart."""
+ deadline = None if timeout is None else time.monotonic() + timeout
+ while True:
+ code = self.exit_code()
+ if code is not None:
+ return code
+ if deadline is not None and time.monotonic() >= deadline:
+ raise TimeoutError(f"GPU job in {self.run_dir} did not finish in time")
+ time.sleep(self.poll_interval)
+
+ def query(self):
+ code = self.exit_code()
+ return {"state": "exited" if code is not None else
+ ("running" if self.alive() else "unknown"),
+ "exit_code": code, "pid": self._pid(),
+ "run_dir": str(self.run_dir)}
+
+ def cancel(self, *, grace_seconds=30):
+ pid = self._pid()
+ if pid is None:
+ return False
+ for signame in ("INT", "TERM", "KILL"):
+ remote.run_ssh(self.spec, f"kill -{signame} -- -{pid} 2>/dev/null || "
+ f"kill -{signame} {pid} 2>/dev/null || true",
+ timeout=self.command_timeout, connect_timeout=self.ssh_timeout)
+ deadline = time.monotonic() + grace_seconds
+ while time.monotonic() < deadline:
+ if self.exit_code() is not None:
+ return True
+ time.sleep(min(2.0, self.poll_interval))
+ return self.exit_code() is not None
+
+
+def local_target(plan, *, environment):
+ """Build the legacy in-process target from a resolved plan."""
+ allowed = {"PATH", "HOME", "USER", "LANG", "LC_ALL", "TMPDIR", "LD_LIBRARY_PATH",
+ "CUDA_VISIBLE_DEVICES", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
+ "http_proxy", "https_proxy", "no_proxy", "PYTHONPATH",
+ "SLIME_DIR", "MEGATRON_DIR", "SGLANG_IMAGE"}
+ child_env = {key: value for key, value in os.environ.items() if key in allowed}
+ child_env["PATH"] = os.pathsep.join(
+ [str(Path(sys.executable).parent), child_env.get("PATH", os.defpath)])
+ child_env["PYTHONPATH"] = os.pathsep.join(filter(None, [
+ child_env.get("SLIME_DIR", ""), child_env.get("MEGATRON_DIR", ""),
+ str(Path(__file__).resolve().parents[2]), child_env.get("PYTHONPATH", ""),
+ ]))
+ child_env.update(environment)
+ from harborrl.config import ROOT
+
+ return LocalTrainingTarget(plan["training_command"], cwd=ROOT, env=child_env)
diff --git a/harborrl/gateway/messages.py b/harborrl/gateway/messages.py
index 479e764eb..9c474817b 100644
--- a/harborrl/gateway/messages.py
+++ b/harborrl/gateway/messages.py
@@ -58,7 +58,7 @@ def convert(payload, *, model, max_output_tokens=8192, count_only=False):
raise ProtocolError("unsupported context management control")
effort = payload.get("output_config")
if effort is not None and (not isinstance(effort, dict) or set(effort) != {"effort"}
- or effort["effort"] not in ("low", "medium", "high")):
+ or effort["effort"] not in ("low", "medium", "high", "xhigh")):
raise ProtocolError("unsupported output configuration")
if not isinstance(payload.get("tools", []), list):
raise ProtocolError("tools must be a list")
@@ -138,7 +138,13 @@ def parse_qwen_output(text, *, response_id, tools):
"""Deterministic Qwen tool markers; malformed model output is never repaired."""
if "" in text or "" in text:
raise ProtocolError("thinking output is not enabled by this profile")
- names = {tool["function"]["name"] for tool in tools}
+ names = {}
+ for tool in tools:
+ name = tool["function"]["name"]
+ alias = name.casefold()
+ if alias in names:
+ raise ProtocolError("ambiguous generated tool alias")
+ names[alias] = name
blocks, offset = [], 0
matches = list(re.finditer(r"\s*(.*?)\s*", text, re.DOTALL))
remainder = re.sub(r"\s*(.*?)\s*", "", text, flags=re.DOTALL)
@@ -152,9 +158,17 @@ def parse_qwen_output(text, *, response_id, tools):
except ValueError as exc:
raise ProtocolError("invalid generated tool JSON") from exc
keys(call, ("name", "arguments"), "generated tool")
- if call.get("name") not in names or not isinstance(call.get("arguments"), dict):
- raise ProtocolError("unknown generated tool or invalid arguments")
- blocks.append({"type": "tool_use", "id": f"toolu_{response_id}_{index}", "name": call["name"], "input": call["arguments"]})
+ generated_name = call.get("name")
+ if generated_name == "finish" and call.get("arguments") == {}:
+ if text[match.end():]:
+ raise ProtocolError("finish marker must be the final output")
+ return blocks
+ canonical_name = names.get(generated_name.casefold()) if isinstance(generated_name, str) else None
+ if canonical_name is None:
+ raise ProtocolError(f"unknown generated tool: {generated_name!r}")
+ if not isinstance(call.get("arguments"), dict):
+ raise ProtocolError("generated tool arguments are not an object")
+ blocks.append({"type": "tool_use", "id": f"toolu_{response_id}_{index}", "name": canonical_name, "input": call["arguments"]})
offset = match.end()
if text[offset:] or not blocks:
blocks.append({"type": "text", "text": text[offset:]})
diff --git a/harborrl/gateway/server.py b/harborrl/gateway/server.py
index 282a95e26..fd011c505 100644
--- a/harborrl/gateway/server.py
+++ b/harborrl/gateway/server.py
@@ -110,7 +110,7 @@ def do_POST(self):
gateway.registry.delivery(aid, message["id"], "delivery_unknown")
status = 401 if isinstance(exc, PermissionError) else 400 if isinstance(exc, (ProtocolError, json.JSONDecodeError)) else 502
# Do not echo backend errors or request contents (credentials may appear there).
- self.reply(status, {"type": "error", "error": {"type": "authentication_error" if status == 401 else "invalid_request_error" if status == 400 else "api_error", "message": type(exc).__name__}})
+ self.reply(status, {"type": "error", "error": {"type": "authentication_error" if status == 401 else "invalid_request_error" if status == 400 else "api_error", "message": f"{type(exc).__name__}: {exc}"}})
server = ThreadingHTTPServer((host, port), Handler)
server.daemon_threads = True
diff --git a/harborrl/gateway/sglang_backend.py b/harborrl/gateway/sglang_backend.py
index f37f594d8..d2efe5e63 100644
--- a/harborrl/gateway/sglang_backend.py
+++ b/harborrl/gateway/sglang_backend.py
@@ -9,24 +9,23 @@
from harborrl.trajectories.native import digest, finite
-from .messages import ProtocolError, parse_qwen_output
+from .messages import ProtocolError
class SGLangBackend:
- def __init__(self, endpoint, tokenizer, *, tokenizer_digest, template_digest,
- timeout=120, max_context=32768, audited_raw_logprobs=False):
+ def __init__(self, endpoint, tokenizer, model_adapter, *, tokenizer_digest,
+ template_digest, timeout=120, max_context=32768,
+ audited_raw_logprobs=False):
self.endpoint = endpoint.rstrip("/")
self.tokenizer = tokenizer
+ self.model_adapter = model_adapter
self.tokenizer_digest, self.template_digest = tokenizer_digest, template_digest
self.timeout, self.max_context = timeout, max_context
self.audited_raw_logprobs = audited_raw_logprobs
self.opener = request.build_opener(request.ProxyHandler({}))
def prepare(self, converted):
- ids = self.tokenizer.apply_chat_template(converted["messages"], tools=converted["tools"] or None,
- tokenize=True, add_generation_prompt=True, enable_thinking=False)
- if not isinstance(ids, list) or not ids or any(type(i) is not int or i < 0 for i in ids):
- raise ProtocolError("tokenizer did not produce actual input IDs")
+ ids = self.model_adapter.render_chat(self.tokenizer, converted)
if len(ids) + converted["sampling"]["max_new_tokens"] > self.max_context:
raise ProtocolError("context budget exceeded; implicit truncation is forbidden")
return ids
@@ -97,7 +96,9 @@ def decode(self, output, payload, converted, identity, response_id):
finish = meta.get("finish_reason", {})
if finish.get("type") not in ("stop", "length"):
raise ValueError("unsupported serving finish reason")
- blocks = parse_qwen_output(text, response_id=response_id, tools=converted["tools"])
+ blocks = self.model_adapter.parse_output(
+ text, response_id=response_id, tools=converted["tools"]
+ )
has_tools = any(b["type"] == "tool_use" for b in blocks)
stop_sequence = finish.get("matched") if isinstance(finish.get("matched"), str) else None
if stop_sequence not in converted["sampling"]["stop"]:
diff --git a/harborrl/gateway/trace.py b/harborrl/gateway/trace.py
index a5a5d3118..5f5ca7c6a 100644
--- a/harborrl/gateway/trace.py
+++ b/harborrl/gateway/trace.py
@@ -94,6 +94,19 @@ def delivery(self, aid, response_id, status):
state["pending"].discard(matches[0]["request_id"])
self.changed.notify_all()
+ def wait_delivered(self, aid, timeout):
+ deadline = time.monotonic() + timeout
+ with self.changed:
+ state = self.attempts[aid]
+ while state["pending"]:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise TimeoutError("policy delivery acknowledgement still pending")
+ self.changed.wait(remaining)
+ if any(turn.get("delivery") not in ("sent", "consumed_confirmed")
+ for turn in state["turns"]):
+ raise ValueError("policy response delivery was not confirmed")
+
def consume(self, aid, response_id, content, evidence_ref):
"""Called only by trusted native-session audit, never by an HTTP policy client."""
with self.lock:
@@ -109,6 +122,13 @@ def consume(self, aid, response_id, content, evidence_ref):
publish(state["root"] / "consumption" / f"{response_id}.json", {"response_id": response_id, "content_digest": digest(content), "evidence_ref": evidence_ref})
turn.update(delivery="consumed_confirmed", consumption_ref=evidence_ref)
+ def unconsumed_responses(self, aid):
+ with self.lock:
+ state = self.attempts[aid]
+ return [{"response_id": turn["response_id"], "content": deepcopy(turn["response"]["content"]),
+ "finish_reason": turn.get("finish_reason"), "is_final": index == len(state["turns"]) - 1}
+ for index, turn in enumerate(state["turns"]) if turn.get("delivery") != "consumed_confirmed"]
+
def close(self, aid):
with self.lock:
state = self.attempts[aid]
diff --git a/harborrl/harnesses/__init__.py b/harborrl/harnesses/__init__.py
new file mode 100644
index 000000000..53da87865
--- /dev/null
+++ b/harborrl/harnesses/__init__.py
@@ -0,0 +1 @@
+"""Agent harness adapters and their protocol boundaries."""
diff --git a/harborrl/harnesses/claude_code.py b/harborrl/harnesses/claude_code.py
new file mode 100644
index 000000000..fb5c27519
--- /dev/null
+++ b/harborrl/harnesses/claude_code.py
@@ -0,0 +1,53 @@
+"""Claude Code harness adapter with explicit credentials and session audit."""
+from __future__ import annotations
+
+import hashlib
+import json
+from pathlib import Path
+
+from harborrl.rollout.harbor_job.audit import audit_session
+from harborrl.rollout.harbor_job.bindings import (
+ claude_config,
+ sanitized_runner_env,
+)
+
+
+class ClaudeCodeHarnessAdapter:
+ harness_id = "claude_code"
+
+ def validate_profile(self, profile, gateway_url):
+ return claude_config(profile, gateway_url, "validation-only")
+
+ def agent_config(self, profile, gateway_url, credential, max_output_tokens=8192):
+ return claude_config(profile, gateway_url, credential, max_output_tokens)
+
+ def runner_environment(self, environment):
+ return sanitized_runner_env(environment)
+
+ def audit_session(self, path, registry, attempt_id):
+ return audit_session(path, registry, attempt_id)
+
+ def audit_empty_final(self, path, registry, attempt_id, audit):
+ """Claude 2.1.141 omits empty final messages from persisted sessions."""
+ missing = registry.unconsumed_responses(attempt_id)
+ if not missing:
+ return
+ if len(missing) != 1 or missing[0] != {
+ "response_id": missing[0]["response_id"], "content": [],
+ "finish_reason": "end_turn", "is_final": True,
+ }:
+ raise ValueError("only one empty final end_turn may use terminal evidence")
+ path = Path(path)
+ raw = path.read_bytes()
+ events = [json.loads(line) for line in raw.splitlines() if line.strip()]
+ results = [event for event in events if event.get("type") == "result"]
+ if len(results) != 1 or events[-1] != results[0]:
+ raise ValueError("expected one final Claude result")
+ result = results[0]
+ expected = {"subtype": "success", "is_error": False, "result": "",
+ "stop_reason": "end_turn", "session_id": audit["session_id"],
+ "terminal_reason": "completed"}
+ if any(result.get(key) != value for key, value in expected.items()) or result.get("is_error") is not False:
+ raise ValueError("empty final response lacks successful matching session result")
+ evidence = f"{path}#sha256={hashlib.sha256(raw).hexdigest()}"
+ registry.consume(attempt_id, missing[0]["response_id"], [], evidence)
diff --git a/harborrl/harnesses/offline_claude.py b/harborrl/harnesses/offline_claude.py
new file mode 100644
index 000000000..cd44d37cd
--- /dev/null
+++ b/harborrl/harnesses/offline_claude.py
@@ -0,0 +1,61 @@
+"""Pinned native Claude executable deployment, without package-manager fallback."""
+import asyncio
+import hashlib
+import json
+import shlex
+from pathlib import Path
+
+from harbor.agents.installed.claude_code import ClaudeCode
+
+from harborrl.trajectories.native import publish
+
+
+def binary_checksum(binary):
+ with binary.open('rb') as source:
+ return hashlib.file_digest(source, 'sha256').hexdigest()
+
+
+class OfflineClaudeCode(ClaudeCode):
+ def __init__(self, *args, installation, **kwargs):
+ self.installation = installation
+ super().__init__(*args, **kwargs)
+
+ async def install(self, environment):
+ lock = self.installation
+ binary = Path(lock['binary'])
+ checksum = await asyncio.to_thread(binary_checksum, binary)
+ if checksum != lock['sha256']:
+ raise ValueError('offline Claude binary checksum mismatch')
+ # Harbor invokes Claude with this HOME-local prefix on every command.
+ probe = ('import json, os, platform; print(json.dumps(dict('
+ 'system=platform.system(), machine=platform.machine(), '
+ 'libc=os.confstr("CS_GNU_LIBC_VERSION"), home=os.environ.get("HOME", ""))))')
+ result = await environment.exec('python3 -c ' + shlex.quote(probe))
+ try:
+ system = json.loads(result.stdout or '')
+ except ValueError:
+ system = {}
+ if (result.return_code or system.get('system') != 'Linux' or system.get('machine') != 'x86_64'
+ or not str(system.get('libc', '')).startswith('glibc ')):
+ raise ValueError('offline binary requires Linux x86_64 glibc and a writable HOME')
+ home = system.get('home', '')
+ if not home.startswith('/') or home == '/':
+ raise ValueError('invalid agent HOME')
+ target = home + '/.local/bin/claude'
+ staging = target + '.harborrl-upload'
+ result = await environment.exec('mkdir -p ' + shlex.quote(home + '/.local/bin'))
+ if result.return_code:
+ raise ValueError('agent tool directory is not writable')
+ await environment.upload_file(binary, staging)
+ command = (f'printf "%s %s\\n" {shlex.quote(lock["sha256"])} {shlex.quote(staging)} | sha256sum -c -'
+ f' && chmod 755 {shlex.quote(staging)} && mv {shlex.quote(staging)} {shlex.quote(target)}')
+ result = await environment.exec(command)
+ if result.return_code:
+ raise ValueError('offline Claude installation checksum/permissions failed')
+ result = await environment.exec(f'{shlex.quote(target)} --version')
+ version = (result.stdout or '').strip().split()
+ if result.return_code or not version or version[0] != lock['version']:
+ raise ValueError('offline Claude installation/version probe failed')
+ publish(self.logs_dir / 'offline-installation.json', {
+ 'version': lock['version'], 'sha256': lock['sha256'], 'target': target,
+ 'platform': system, 'mode': lock['mode']})
diff --git a/harborrl/math_registry.py b/harborrl/math_registry.py
new file mode 100644
index 000000000..61f0a2b37
--- /dev/null
+++ b/harborrl/math_registry.py
@@ -0,0 +1,316 @@
+"""Explicit MATH component contracts and startup compatibility checks."""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Protocol
+
+from harborrl.algorithms.grpo import GRPOAlgorithmAdapter
+from harborrl.backends.slime_v032.versions import BACKEND_CONTRACT
+from harborrl.harnesses.claude_code import ClaudeCodeHarnessAdapter
+from harborrl.models.qwen import QwenModelAdapter
+from harborrl.tasks.jsonl_provider import JsonlTaskProvider
+from harborrl.tasks.provider import NativeTaskProvider
+
+
+class ComponentRegistry:
+ def __init__(self, dimension):
+ self.dimension = dimension
+ self._components = {}
+
+ def register(self, spec):
+ if spec.id in self._components:
+ raise ValueError(f"duplicate {self.dimension} registration: {spec.id}")
+ self._components[spec.id] = spec
+ return spec
+
+ def get(self, component_id):
+ try:
+ return self._components[component_id]
+ except KeyError as exc:
+ raise ValueError(
+ f"unsupported {self.dimension}: {component_id}; "
+ f"registered: {', '.join(sorted(self._components))}"
+ ) from exc
+
+ def items(self):
+ return tuple(self._components[spec_id] for spec_id in sorted(self._components))
+
+
+@dataclass(frozen=True)
+class ModelSpec:
+ id: str
+ display_name: str
+ preset_prefix: str
+ serving_backend: str
+ tool_protocol: str
+ gateway_protocols: frozenset[str]
+ training_backend_contracts: frozenset[str]
+ adapter: QwenModelAdapter
+ version: str
+ evidence: tuple[str, ...]
+ limitations: tuple[str, ...]
+
+
+@dataclass(frozen=True)
+class AlgorithmSpec:
+ id: str
+ display_name: str
+ training_backend_contracts: frozenset[str]
+ minimum_group_size: int
+ requires_audited_raw_logprobs: bool
+ reduction: str
+ adapter: GRPOAlgorithmAdapter
+ backend_hooks: dict[str, str]
+ version: str
+ evidence: tuple[str, ...]
+ limitations: tuple[str, ...]
+
+
+@dataclass(frozen=True)
+class TaskProviderSpec:
+ id: str
+ display_name: str
+ execution_backends: frozenset[str]
+ task_format: str
+ adapter: TaskProviderAdapter
+ version: str
+ evidence: tuple[str, ...]
+ limitations: tuple[str, ...]
+
+
+@dataclass(frozen=True)
+class HarnessSpec:
+ id: str
+ display_name: str
+ request_protocol: str
+ tool_protocol: str
+ harbor_versions: frozenset[str]
+ adapter: ClaudeCodeHarnessAdapter
+ version: str
+ evidence: tuple[str, ...]
+ limitations: tuple[str, ...]
+
+
+class ModelAdapter(Protocol):
+ def render_chat(self, tokenizer, converted): ...
+
+ def parse_output(self, text, *, response_id, tools): ...
+
+
+class AlgorithmAdapter(Protocol):
+ def compute_advantages_and_returns(self, args, rollout_data): ...
+
+
+class TaskAdapter(Protocol):
+ def discover(self, tasks_dir): ...
+
+ def inspect(self, path, *, expected_digest=None): ...
+
+ def catalog(self, config): ...
+
+
+class TaskProviderAdapter(Protocol):
+ def discover(self, tasks_dir): ...
+
+ def inspect(self, path, *, expected_digest=None): ...
+
+ def catalog(self, config): ...
+
+ def prompt_rows(self, entries): ...
+
+
+class HarnessAdapter(Protocol):
+ def validate_profile(self, profile, gateway_url): ...
+
+ def agent_config(self, profile, gateway_url, credential, max_output_tokens=8192): ...
+
+QWEN_MODEL = ModelSpec(
+ id="qwen",
+ display_name="Qwen",
+ preset_prefix="qwen",
+ serving_backend="sglang",
+ tool_protocol="qwen-tool-call-v1",
+ gateway_protocols=frozenset({"anthropic-messages-v1"}),
+ training_backend_contracts=frozenset({BACKEND_CONTRACT}),
+ adapter=QwenModelAdapter(),
+ version="qwen3-profile-v1",
+ evidence=("examples/native/train_qwen_native.yaml",),
+ limitations=("preset must start with qwen", "tokenizer and template digests are checked live"),
+)
+GRPO_ALGORITHM = AlgorithmSpec(
+ id="grpo",
+ display_name="Slime GRPO",
+ training_backend_contracts=frozenset({BACKEND_CONTRACT}),
+ minimum_group_size=2,
+ requires_audited_raw_logprobs=True,
+ reduction="global-sum",
+ adapter=GRPOAlgorithmAdapter(),
+ backend_hooks={
+ "rollout": "harborrl.backends.slime_v032.rollout.generate_rollout",
+ "converter": "harborrl.backends.slime_v032.converter.convert_samples_to_train_data",
+ "advantage": "harborrl.backends.slime_v032.advantage.compute_advantages_and_returns",
+ "loss": "harborrl.backends.slime_v032.loss.loss_function",
+ "postprocess": "harborrl.backends.slime_v032.postprocess.rollout_data_postprocess",
+ },
+ version="native-centered-grpo-v1",
+ evidence=("harborrl/backends/slime_v032/loss.py",),
+ limitations=("requires complete reward-varied groups", "only Slime v0.3.2 hooks are accepted"),
+)
+HARBOR_JSONL_TASK_PROVIDER = TaskProviderSpec(
+ id="harbor_jsonl",
+ display_name="Harbor JSONL",
+ execution_backends=frozenset({"harbor_job"}),
+ task_format="native_single_step_jsonl_v1",
+ adapter=JsonlTaskProvider(),
+ version="jsonl-catalog-lock-v1",
+ evidence=("examples/native/catalog.jsonl",),
+ limitations=(
+ "one task lock must be encoded per JSONL line",
+ "tasks must be fetched before cataloging",
+ "task selectors use catalog IDs",
+ ),
+)
+NATIVE_TASK_PROVIDER = TaskProviderSpec(
+ id="native_harbor",
+ display_name="Harbor",
+ execution_backends=frozenset({"harbor_job"}),
+ task_format="native_single_step_text_v1",
+ adapter=NativeTaskProvider(),
+ version="catalog-lock-v1",
+ evidence=("examples/native/catalog.json",),
+ limitations=("tasks must be fetched before cataloging", "task selectors use catalog IDs"),
+)
+CLAUDE_CODE_HARNESS = HarnessSpec(
+ id="claude_code",
+ display_name="Claude Code",
+ request_protocol="anthropic-messages-v1",
+ tool_protocol="qwen-tool-call-v1",
+ harbor_versions=frozenset({"0.23.0"}),
+ adapter=ClaudeCodeHarnessAdapter(),
+ version="claude-code-session-audit-v1",
+ evidence=("harborrl/rollout/harbor_job/audit.py",),
+ limitations=("exact CLI version is required", "ambient credentials are rejected"),
+)
+class MATHRegistry:
+ def __init__(self):
+ self.models = ComponentRegistry("model")
+ self.algorithms = ComponentRegistry("algorithm")
+ self.tasks = ComponentRegistry("task provider")
+ self.harnesses = ComponentRegistry("harness")
+ self.models.register(QWEN_MODEL)
+ self.algorithms.register(GRPO_ALGORITHM)
+ self.tasks.register(NATIVE_TASK_PROVIDER)
+ self.tasks.register(HARBOR_JSONL_TASK_PROVIDER)
+ self.harnesses.register(CLAUDE_CODE_HARNESS)
+
+ def model_for_preset(self, preset):
+ for spec in self.models.items():
+ if str(preset).startswith(spec.preset_prefix):
+ return spec
+ raise ValueError(
+ f"unsupported model preset: {preset}; "
+ f"registered: {', '.join(spec.id for spec in self.models.items())}"
+ )
+
+ def algorithm_for_contract(self, contract):
+ for spec in self.algorithms.items():
+ if contract in spec.training_backend_contracts:
+ return spec
+ raise ValueError(f"no registered algorithm supports backend contract: {contract}")
+
+
+MATH_REGISTRY = MATHRegistry()
+
+
+@dataclass(frozen=True)
+class MATHSelection:
+ model: ModelSpec
+ algorithm: AlgorithmSpec
+ task: TaskProviderSpec
+ harness: HarnessSpec
+
+ def asdict(self):
+ return {
+ "model": _describe(self.model),
+ "algorithm": _describe(self.algorithm),
+ "task": _describe(self.task),
+ "harness": _describe(self.harness),
+ }
+
+
+def _describe(spec):
+ return {
+ "id": spec.id,
+ "display_name": spec.display_name,
+ "version": spec.version,
+ "evidence": list(spec.evidence),
+ "limitations": list(spec.limitations),
+ }
+
+
+def select_math(config):
+ return MATHSelection(
+ model=MATH_REGISTRY.model_for_preset(config["model"]["args_file"]),
+ algorithm=MATH_REGISTRY.algorithm_for_contract(
+ config["training"]["backend_contract"]
+ ),
+ task=MATH_REGISTRY.tasks.get(
+ "harbor_jsonl"
+ if str(config.get("tasks", {}).get("catalog", "")).endswith(".jsonl")
+ else "native_harbor"
+ ),
+ harness=MATH_REGISTRY.harnesses.get(config["harness"]["name"]),
+ )
+
+
+def validate_combination(selection, config):
+ contract = config["training"]["backend_contract"]
+ if contract not in selection.model.training_backend_contracts:
+ raise ValueError(
+ f"model {selection.model.id} does not support training backend {contract}"
+ )
+ if contract not in selection.algorithm.training_backend_contracts:
+ raise ValueError(
+ f"algorithm {selection.algorithm.id} does not support training backend {contract}"
+ )
+ if config["execution"]["backend"] not in selection.task.execution_backends:
+ raise ValueError(
+ f"task provider {selection.task.id} does not support execution backend "
+ f"{config['execution']['backend']}"
+ )
+ if config["harbor"]["version"] not in selection.harness.harbor_versions:
+ raise ValueError(
+ f"harness {selection.harness.id} does not support Harbor "
+ f"{config['harbor']['version']}"
+ )
+ if selection.model.tool_protocol != selection.harness.tool_protocol:
+ raise ValueError("model and harness tool protocols do not match")
+ if selection.harness.request_protocol not in selection.model.gateway_protocols:
+ raise ValueError(
+ f"model gateway does not support harness protocol {selection.harness.request_protocol}"
+ )
+ if config["sampling"]["group_size"] < selection.algorithm.minimum_group_size:
+ raise ValueError(
+ f"algorithm {selection.algorithm.id} requires group size >= "
+ f"{selection.algorithm.minimum_group_size}"
+ )
+ if (
+ selection.algorithm.requires_audited_raw_logprobs
+ and not config["gateway"]["audited_raw_logprobs"]
+ ):
+ raise ValueError(
+ f"algorithm {selection.algorithm.id} requires audited raw logprobs"
+ )
+ return selection
+
+
+def list_components():
+ return {
+ dimension: [_describe(spec) for spec in registry.items()]
+ for dimension, registry in (
+ ("models", MATH_REGISTRY.models),
+ ("algorithms", MATH_REGISTRY.algorithms),
+ ("tasks", MATH_REGISTRY.tasks),
+ ("harnesses", MATH_REGISTRY.harnesses),
+ )
+ }
diff --git a/harborrl/models/__init__.py b/harborrl/models/__init__.py
new file mode 100644
index 000000000..89314d47e
--- /dev/null
+++ b/harborrl/models/__init__.py
@@ -0,0 +1 @@
+"""Model family adapters used by serving and training boundaries."""
diff --git a/harborrl/models/qwen.py b/harborrl/models/qwen.py
new file mode 100644
index 000000000..cbd096dad
--- /dev/null
+++ b/harborrl/models/qwen.py
@@ -0,0 +1,39 @@
+"""Qwen tokenizer/template and generated tool-call protocol."""
+from __future__ import annotations
+
+from harborrl.gateway.messages import ProtocolError, parse_qwen_output
+
+
+def normalize_chat_template_ids(rendered):
+ """Accept both legacy lists and modern ``BatchEncoding`` results."""
+ if hasattr(rendered, "get"):
+ rendered = rendered.get("input_ids")
+ if isinstance(rendered, (list, tuple)):
+ if rendered and isinstance(rendered[0], (list, tuple)):
+ rendered = rendered[0]
+ return list(rendered)
+ return rendered
+
+
+class QwenModelAdapter:
+ model_id = "qwen"
+
+ def render_chat(self, tokenizer, converted):
+ rendered_ids = tokenizer.apply_chat_template(
+ converted["messages"],
+ tools=converted["tools"] or None,
+ tokenize=True,
+ add_generation_prompt=True,
+ enable_thinking=False,
+ )
+ token_ids = normalize_chat_template_ids(rendered_ids)
+ if (
+ not isinstance(token_ids, list)
+ or not token_ids
+ or any(type(token_id) is not int or token_id < 0 for token_id in token_ids)
+ ):
+ raise ProtocolError("tokenizer did not produce actual input IDs")
+ return token_ids
+
+ def parse_output(self, text, *, response_id, tools):
+ return parse_qwen_output(text, response_id=response_id, tools=tools)
diff --git a/harborrl/options/__init__.py b/harborrl/options/__init__.py
new file mode 100644
index 000000000..a53f7a1cc
--- /dev/null
+++ b/harborrl/options/__init__.py
@@ -0,0 +1,11 @@
+"""Offline configuration discovery and generation; no optional runtime imports."""
+
+
+def build_catalog():
+ from .catalog import build_catalog as build
+ return build()
+
+
+def build_config_bundle(answers, *, output_directory):
+ from .wizard import build_config_bundle as build
+ return build(answers, output_directory=output_directory)
diff --git a/harborrl/options/catalog.py b/harborrl/options/catalog.py
new file mode 100644
index 000000000..76ede0794
--- /dev/null
+++ b/harborrl/options/catalog.py
@@ -0,0 +1,147 @@
+"""JSON-safe option metadata. Existing validators remain authoritative."""
+from copy import deepcopy
+from importlib.resources import files
+
+import yaml
+
+from harborrl.config import ROOT
+from harborrl.config.native import FIELDS, OPTIONAL
+from harborrl.deployment import schema
+from harborrl.math_registry import MATH_REGISTRY
+
+from .presets import PRESETS
+
+TRAINING_FIELDS = {
+ "consistency.mode": ("string", "off, audit or enforce"),
+ "consistency.max_abs_delta": ("number_or_null", "Explicit deployment maximum token log-probability delta"),
+ "consistency.max_mean_abs_delta": ("number_or_null", "Explicit deployment mean token delta"),
+ "consistency.bad_token_delta": ("number_or_null", "Threshold identifying a bad token"),
+ "consistency.max_bad_fraction": ("number", "Allowed bad-token fraction between zero and one"),
+ "consistency.worst_tokens": ("integer", "Maximum diagnostic tokens, zero through 32"),
+ "execution.backend": ("string", "Task execution backend; currently harbor_job"),
+ "tasks.catalog": ("path", "Existing JSON/JSONL Harbor task lock; resolved on the controller"),
+ "harness.name": ("string", "Registered agent harness; currently claude_code"),
+ "harness.profile": ("path", "Pinned harness profile JSON on the controller"),
+ "harbor.python": ("path", "Runner interpreter on the local runner or SSH worker"),
+ "harbor.version": ("string", "Harbor version supported by the selected harness"),
+ "harbor.workers": ("array", "Ordered SSH destinations; empty with a local runner"),
+ "harbor.runner_transport": ("string", "local or ssh; local requires no workers"),
+ "harbor.environment": ("object", "Docker runtime, or Sandbox runtime with a config file reference"),
+ "gateway.host": ("string", "Gateway listener address, distinct from its advertised origin"),
+ "gateway.port": ("integer", "Gateway listener port, 1 through 65535"),
+ "gateway.advertised_url": ("string", "Plain HTTP(S) origin reachable by runners; port must match"),
+ "gateway.tokenizer_digest": ("string", "SHA-256 of the actual tokenizer fingerprint; never a placeholder"),
+ "gateway.template_digest": ("string", "SHA-256 of the actual chat-template fingerprint"),
+ "gateway.audited_raw_logprobs": ("boolean", "Raw serving probability audit, required by GRPO"),
+ "model.checkpoint": ("path", "Student model checkpoint; must be accessible on the GPU target"),
+ "model.reference": ("path", "Reference checkpoint accessible on the GPU target"),
+ "model.args_file": ("string", "Supported Slime preset name; currently the qwen prefix"),
+ "training.num_rollout": ("integer", "Positive number of rollout batches"),
+ "training.learning_rate": ("number", "Positive finite learning rate"),
+ "training.save_interval": ("integer", "Positive checkpoint interval in rollout batches"),
+ "training.backend_contract": ("string", "Pinned backend/algorithm contract"),
+ "sampling.group_size": ("integer", "Samples per prompt, at least two for GRPO"),
+ "sampling.groups_per_batch": ("integer", "Positive number of complete groups per batch"),
+ "sampling.max_attempts": ("integer", "Bounded invalid-attempt retries; not low-reward retries"),
+ "sampling.max_tokens": ("integer", "Positive response limit below max_context"),
+ "sampling.max_context": ("integer", "Total context token limit"),
+ "deployment.layout": ("string", "split or colocate; controls actor/serving GPU allocation"),
+ "deployment.num_gpus": ("integer", "Total positive GPU budget, not a memory-fit guarantee"),
+ "deployment.actor_gpus": ("integer", "Actor GPU count; must be divisible by actor TP"),
+ "deployment.rollout_gpus": ("integer", "Serving GPU count; must be divisible by engine TP"),
+ "deployment.actor_tensor_parallel_size": ("integer", "Positive actor tensor-parallel size"),
+ "deployment.rollout_gpus_per_engine": ("integer", "Positive serving tensor-parallel size"),
+ "output.root": ("path", "Run output root; service mode must match shared storage.run_root"),
+}
+
+DEPLOYMENT_GROUPS = {
+ "manager": schema.MANAGER_FIELDS, "gpu_target": schema.GPU_FIELDS,
+ "storage": schema.STORAGE_FIELDS, "rollout": schema.ROLLOUT_FIELDS,
+ "rollout.workers[]": schema.WORKER_FIELDS, "gpu_target.ssh": schema.SSH_FIELDS,
+ "rollout.workers[].ssh": schema.SSH_FIELDS, "connectivity": schema.CONNECTIVITY_FIELDS,
+}
+DEPLOYMENT_FIELDS = {
+ "id": ("string", "Short role identifier"), "mode": ("string", "Manager mode or shared storage mode"),
+ "bind": ("string", "Manager listener address"),
+ "control_port": ("integer", "Control listener port; different from gateway_port"),
+ "control_url": ("string", "Control origin reachable from the GPU target"),
+ "gateway_port": ("integer", "Gateway listener port"),
+ "gateway_url": ("string", "Gateway origin reachable from runners or the configured relay"),
+ "run_token_file": ("path", "Manager run-token file reference; never the secret value"),
+ "source_dir": ("path", "Absolute source checkout path on this role's machine"),
+ "controller_python": ("path", "Absolute interpreter path on the controller"),
+ "transport": ("string", "GPU launch transport: local or ssh"),
+ "ssh": ("object", "SSH specification; GPU local transport requires null"),
+ "train_python": ("path", "Absolute interpreter path on the GPU target"),
+ "environment_file": ("path_or_null", "Controlled JSON environment file on the GPU target"),
+ "expected_gpus": ("integer", "Expected target GPU inventory, at least the requested budget"),
+ "run_root": ("path", "Absolute shared run root visible to all participating roles"),
+ "manager_writable": ("boolean", "Declared shared-root access; verified by doctor"),
+ "gpu_writable": ("boolean", "Declared GPU shared-root access; verified by doctor"),
+ "worker_writable": ("boolean", "Declared worker shared-root access; verified by doctor"),
+ "provider": ("string", "Rollout placement/provider; not a dataset or algorithm"),
+ "workers": ("array", "Ordered worker role specifications; empty for local Sandbox runners"),
+ "runner_python": ("path", "Absolute interpreter path on the worker"),
+ "docker": ("boolean", "Worker Docker requirement"),
+ "task_roots": ("array", "Absolute task roots visible on this worker"),
+ "host": ("string", "SSH alias/destination, not an automatically verified host"),
+ "user": ("string", "Optional SSH username"), "port": ("integer", "Optional SSH port"),
+ "identity": ("path", "Optional identity-file reference; no private-key contents"),
+ "command_timeout_s": ("integer", "Positive remote command timeout"),
+ "ssh_timeout_s": ("integer", "Positive SSH connection timeout"),
+ "http_timeout_s": ("integer", "Positive HTTP check timeout"),
+ "gpu_to_controller": ("boolean", "Declared GPU-to-controller connectivity requirement"),
+ "worker_to_gateway": ("boolean", "Declared runner-to-gateway connectivity requirement"),
+ "manager_to_gpu_inference": ("boolean", "Declared controller-to-serving connectivity requirement"),
+}
+
+
+def _describe(spec, attributes):
+ record = {}
+ for key in ("id", "display_name", "version", "evidence", "limitations", *attributes):
+ value = getattr(spec, key)
+ record[key] = sorted(value) if isinstance(value, frozenset) else list(value) if isinstance(value, tuple) else value
+ return record
+
+
+def dataset_metadata():
+ return yaml.safe_load(files("harborrl.options").joinpath("resources/datasets.yaml").read_text(encoding="utf-8"))
+
+
+def build_catalog():
+ training = {}
+ for section, fields in sorted(FIELDS.items()):
+ for name in sorted(fields):
+ key = f"{section}.{name}"
+ kind, description = TRAINING_FIELDS[key]
+ training[key] = {"type": kind, "description": description,
+ "required": name not in OPTIONAL.get(section, set())}
+ deployment = {}
+ for section, fields in sorted(DEPLOYMENT_GROUPS.items()):
+ for name in sorted(fields):
+ kind, description = DEPLOYMENT_FIELDS[name]
+ deployment[f"{section}.{name}"] = {"type": kind, "description": description}
+ for key, values in {"manager.mode": ("embedded", "service"), "storage.mode": schema.STORAGE_MODES,
+ "rollout.provider": schema.ROLLOUT_PROVIDERS, "gpu_target.transport": schema.TRANSPORTS}.items():
+ deployment[key]["values"] = list(values)
+ datasets = deepcopy(dataset_metadata()["datasets"])
+ for item in datasets:
+ path = ROOT / item["task_root"]
+ item["available"] = item["status"] != "planned" and item["source"] == "bundled" and path.is_dir()
+ item["availability_note"] = "Provide an existing inspected task catalog; no download is performed"
+ registry = MATH_REGISTRY
+ return {
+ "catalog_version": 1, "schema_versions": {"training": 2, "deployment": 1},
+ "models": [_describe(s, ("preset_prefix", "serving_backend", "tool_protocol", "gateway_protocols", "training_backend_contracts")) for s in registry.models.items()],
+ "algorithms": [_describe(s, ("training_backend_contracts", "minimum_group_size", "requires_audited_raw_logprobs", "reduction")) for s in registry.algorithms.items()],
+ "task_providers": [_describe(s, ("execution_backends", "task_format")) for s in registry.tasks.items()],
+ "harnesses": [_describe(s, ("request_protocol", "tool_protocol", "harbor_versions")) for s in registry.harnesses.items()],
+ "datasets": datasets, "deployment_presets": deepcopy(list(PRESETS)),
+ "fields": {"training": training, "deployment": deployment},
+ "compatibility": {"scope": "component_metadata", "status": "unknown_until_configuration_validated",
+ "constraints": ["Model and harness tool/request protocols must match",
+ "Algorithm and model must support the selected backend contract",
+ "Task provider must support the execution backend",
+ "Harbor version must be supported by the harness"],
+ "validator": "harborrl.math_registry.validate_combination"},
+ }
diff --git a/harborrl/options/cli.py b/harborrl/options/cli.py
new file mode 100644
index 000000000..7438628dc
--- /dev/null
+++ b/harborrl/options/cli.py
@@ -0,0 +1,184 @@
+"""Terminal interface for the offline catalog, validation and bundle builder."""
+import argparse
+import json
+import shlex
+import sys
+from pathlib import Path
+
+import yaml
+
+from .catalog import build_catalog
+from .presets import PRESETS, worker_defaults
+from .validation import read_mapping, validate_paths
+from .wizard import build_config_bundle, publish_bundle
+
+DIMENSIONS = {"models": "models", "algorithms": "algorithms", "tasks": "task_providers",
+ "harnesses": "harnesses", "datasets": "datasets", "deployments": "deployment_presets",
+ "fields": "fields", "compat": "compatibility"}
+
+
+def ask(label, default=None, convert=str):
+ while True:
+ value = input(f"{label}" + (f" [{default}]" if default is not None else "") + ": ").strip()
+ if not value and default is not None:
+ return default
+ try:
+ if not value:
+ raise ValueError("A value is required")
+ return convert(value)
+ except ValueError as exc:
+ print(str(exc), file=sys.stderr)
+
+
+def choose(label, choices):
+ for index, value in enumerate(choices, 1):
+ print(f" {index}. {value}")
+ while True:
+ index = ask(label, 1, int)
+ if 1 <= index <= len(choices):
+ return choices[index - 1]
+ print("Choose a listed number", file=sys.stderr)
+
+
+def interactive_answers():
+ preset = choose("Deployment", [p["id"] for p in PRESETS])
+ t, d = {}, {}
+ service = preset != "embedded-local"
+ print("Components: Qwen / GRPO / Harbor tasks / Claude Code. Advanced fields use an answers file.")
+ if service:
+ d["manager"] = {
+ "source_dir": ask("Controller source directory"),
+ "controller_python": ask("Controller Python", sys.executable),
+ "control_url": ask("Controller origin (include port 49200)"),
+ "gateway_url": ask("Gateway origin (include port 49184)"),
+ }
+ d["storage"] = {"run_root": ask("Shared run root (absolute)")}
+ d["gpu_target"] = {"ssh": {"host": ask("GPU SSH alias/destination")},
+ "source_dir": ask("GPU source directory"), "train_python": ask("GPU training Python")}
+ else:
+ d["gpu_target"] = {"train_python": ask("Training Python", sys.executable)}
+ if preset == "service-ssh":
+ workers = []
+ count = ask("Number of SSH runners", 1, int)
+ if count < 1:
+ raise ValueError("At least one SSH runner is required")
+ for index in range(count):
+ worker = worker_defaults()
+ worker.update(id=f"worker-{index}", ssh={"host": ask(f"Runner {index} SSH destination")},
+ source_dir=ask("Runner source directory"), runner_python=ask("Runner Python"),
+ task_roots=[ask("Runner task root")])
+ workers.append(worker)
+ d["rollout"] = {"workers": workers}
+ else:
+ t["harbor"] = {"python": ask("Local Harbor runner Python", sys.executable)}
+ t["tasks"] = {"catalog": ask("Existing task catalog JSON/JSONL")}
+ t["harness"] = {"profile": ask("Existing harness profile JSON")}
+ t["model"] = {"checkpoint": ask("GPU checkpoint directory"), "reference": ask("GPU reference directory"),
+ "args_file": ask("Qwen model args preset", "qwen3-8B")}
+ t["gateway"] = {"tokenizer_digest": ask("Actual tokenizer SHA-256"),
+ "template_digest": ask("Actual template SHA-256")}
+ if not service:
+ t["gateway"].update(host=ask("Gateway bind address", "127.0.0.1"),
+ advertised_url=ask("Gateway origin reachable by tasks", "http://127.0.0.1:49184"))
+ layout = choose("GPU layout", ["colocate", "split"])
+ t["deployment"] = {"layout": layout, "num_gpus": ask("Total GPU budget", 1, int)}
+ if layout == "split":
+ t["deployment"].update(actor_gpus=ask("Actor GPUs", 1, int), rollout_gpus=ask("Serving GPUs", 1, int))
+ t["training"] = {"num_rollout": ask("Rollout batches", 2, int)}
+ t["sampling"] = {"group_size": ask("Samples per group", 2, int),
+ "groups_per_batch": ask("Groups per batch", 1, int)}
+ answers = {"answers_version": 1, "preset": preset, "training": t, "deployment": d}
+ if preset == "service-sandbox-local-runner":
+ answers["sandbox_config"] = ask("Existing Sandbox configuration")
+ return answers
+
+
+def render_validation(result):
+ print(f"{result['status']} (scope: {result['scope']})")
+ for row in result["checks"]:
+ print(f" {row['status']}: {row['path']}: {row['message']}")
+
+
+def render_catalog(result):
+ print("HarborRL options (catalog 1; training schema 2; deployment schema 1)")
+ for name, value in result.items():
+ if name in {"catalog_version", "schema_versions"}:
+ continue
+ if name == "fields" and len(result) > 3:
+ print("fields: use 'harborrl options fields' for field descriptions")
+ elif isinstance(value, list):
+ print(f"{name}:")
+ for row in value:
+ description = row.get("description", row.get("display_name", ""))
+ if "available" in row:
+ description += f" (status={row['status']}, locally_available={row['available']})"
+ print(f" {row.get('id', row.get('name'))}: {description}")
+ else:
+ print(yaml.safe_dump({name: value}, sort_keys=False).rstrip())
+
+
+def main(argv):
+ parser = argparse.ArgumentParser(prog="harborrl")
+ commands = parser.add_subparsers(dest="command", required=True)
+ options = commands.add_parser("options", help="List supported configuration options")
+ options.add_argument("dimension", nargs="?", choices=DIMENSIONS)
+ options.add_argument("--json", action="store_true")
+ validate = commands.add_parser("validate", help="Validate offline; no runtime is started")
+ validate.add_argument("config")
+ validate.add_argument("--deployment")
+ validate.add_argument("--check-files", action="store_true")
+ validate.add_argument("--json", action="store_true")
+ init = commands.add_parser("init", help="Create a configuration bundle in a new directory")
+ init.add_argument("--out-dir", required=True)
+ init.add_argument("--answers")
+ init.add_argument("--probe-local", action="store_true")
+ args = parser.parse_args(argv)
+ try:
+ if args.command == "options":
+ result = build_catalog()
+ if args.dimension:
+ key = DIMENSIONS[args.dimension]
+ result = {k: result[k] for k in ("catalog_version", "schema_versions", key)}
+ if args.json:
+ print(json.dumps(result, indent=2, sort_keys=True))
+ else:
+ render_catalog(result)
+ return 0
+ if args.command == "validate":
+ result = validate_paths(args.config, args.deployment, check_files=args.check_files)
+ if args.json:
+ print(json.dumps(result, indent=2, sort_keys=True))
+ else:
+ render_validation(result)
+ return int(result["status"] == "invalid")
+ if not args.answers and not sys.stdin.isatty():
+ parser.error("init requires --answers when stdin is not a terminal")
+ output = Path(args.out_dir).expanduser().absolute()
+ if output.exists() or output.is_symlink():
+ raise ValueError(f"Output directory already exists: {output}")
+ if args.probe_local:
+ from .probe import probe_local
+ print(json.dumps(probe_local(output), indent=2))
+ answers = read_mapping(args.answers) if args.answers else interactive_answers()
+ bundle = build_config_bundle(answers, output_directory=output)
+ render_validation(bundle["validation"])
+ if not args.answers:
+ print(bundle["files"]["config.yaml"])
+ print(bundle["files"]["deployment.yaml"])
+ if ask("Publish these files? (yes/no)", "no").lower() != "yes":
+ print("Cancelled; no files written")
+ return 0
+ publish_bundle(bundle, output)
+ print(f"Created configuration bundle: {output}")
+ config = shlex.quote(str(output / "config.yaml"))
+ deployment = shlex.quote(str(output / "deployment.yaml"))
+ for command, suffix in (("validate", " --check-files"), ("train", " --dry-run"), ("doctor", "")):
+ print(f"harborrl {command} {config} --deployment {deployment}{suffix}")
+ print("doctor checks the live environment and connectivity; training requires a separate command.")
+ return 0
+ except (ValueError, TypeError, KeyError, AttributeError, OSError, yaml.YAMLError) as exc:
+ print(f"error: {exc}", file=sys.stderr)
+ return 1
+ except (EOFError, KeyboardInterrupt):
+ print("Cancelled; no configuration published", file=sys.stderr)
+ return 130
diff --git a/harborrl/options/presets.py b/harborrl/options/presets.py
new file mode 100644
index 000000000..6cc7ef8b5
--- /dev/null
+++ b/harborrl/options/presets.py
@@ -0,0 +1,153 @@
+"""Small configuration recipes; final validity belongs to existing schemas."""
+import sys
+from copy import deepcopy
+from pathlib import Path
+
+from harborrl.config import ROOT
+from harborrl.consistency.config import DEFAULTS as CONSISTENCY_DEFAULTS
+from harborrl.deployment.schema import destination, embedded_local
+
+PRESETS = (
+ {"id": "embedded-local", "description": "Local training and a local Harbor runner"},
+ {"id": "service-ssh", "description": "Controller, SSH GPU target and SSH Docker runners"},
+ {"id": "service-sandbox-local-runner", "description": "Controller and runner here; GPU target over SSH; Sandbox execution"},
+)
+
+
+def merge(base, override, path=""):
+ if not isinstance(override, dict):
+ raise TypeError(f"{path or 'answers'} must be a mapping")
+ result = deepcopy(base)
+ for key, value in override.items():
+ if key not in base:
+ raise ValueError(f"unknown field: {path}{key}")
+ result[key] = (merge(base[key], value, f"{path}{key}.")
+ if isinstance(base[key], dict) and base[key] else deepcopy(value))
+ return result
+
+
+def defaults(preset, output_directory):
+ if preset not in {item["id"] for item in PRESETS}:
+ raise ValueError(f"unknown preset: {preset}")
+ output = Path(output_directory).resolve()
+ service = preset != "embedded-local"
+ sandbox = preset == "service-sandbox-local-runner"
+ training = {
+ "consistency": deepcopy(CONSISTENCY_DEFAULTS),
+ "schema_version": 2,
+ "execution": {"backend": "harbor_job"}, "tasks": {"catalog": None},
+ "harness": {"name": "claude_code", "profile": None},
+ "harbor": {"python": None, "version": "0.23.0", "workers": [],
+ "runner_transport": "ssh" if preset == "service-ssh" else "local",
+ "environment": {"runtime": "sandbox", "config": None} if sandbox else {"runtime": "docker"}},
+ "gateway": {"host": "0.0.0.0" if service else "127.0.0.1", "port": 49184,
+ "advertised_url": None if service else "http://127.0.0.1:49184",
+ "tokenizer_digest": None, "template_digest": None, "audited_raw_logprobs": True},
+ "model": {"checkpoint": None, "reference": None, "args_file": "qwen3-8B"},
+ "training": {"num_rollout": 2, "learning_rate": 1e-6, "save_interval": 1,
+ "backend_contract": "slime-v0.3.2-native-v1"},
+ "sampling": {"group_size": 2, "groups_per_batch": 1, "max_attempts": 1,
+ "max_tokens": 2048, "max_context": 32768},
+ "deployment": {"layout": "colocate", "num_gpus": 1, "actor_gpus": 1,
+ "rollout_gpus": 1, "actor_tensor_parallel_size": 1, "rollout_gpus_per_engine": 1},
+ "output": {"root": str(output / "runs")},
+ }
+ deployment = embedded_local(run_root=str(output / "runs"))
+ deployment["gpu_target"]["environment_file"] = None
+ deployment["manager"]["run_token_file"] = str(output / "run-token")
+ if service:
+ deployment["manager"].update(id="controller", mode="service", bind="0.0.0.0",
+ control_url=None, gateway_url=None)
+ deployment["gpu_target"].update(id="gpu", transport="ssh", ssh={"host": None, "user": None, "port": None, "identity": None},
+ source_dir=None, train_python=None)
+ deployment["rollout"]["provider"] = "sandbox" if sandbox else "ssh_docker"
+ return training, deployment
+
+
+def build_configs(answers, output_directory):
+ if not isinstance(answers, dict) or set(answers) - {
+ "answers_version", "preset", "training", "deployment", "sandbox_config"}:
+ raise ValueError("unknown answers field or non-mapping answers")
+ if type(answers.get("answers_version")) is not int or answers["answers_version"] != 1:
+ raise ValueError("answers_version must be 1")
+ preset = answers.get("preset")
+ training, deployment = defaults(preset, output_directory)
+ t = answers.get("training", {})
+ d = answers.get("deployment", {})
+ training = merge(training, t, "training.")
+ deployment = merge(deployment, d, "deployment.")
+
+ # Repeated fields have one source unless both were explicitly supplied.
+ for section, field, other_section, other_field in (
+ ("gateway", "host", "manager", "bind"),
+ ("gateway", "port", "manager", "gateway_port"),
+ ("gateway", "advertised_url", "manager", "gateway_url"),
+ ("output", "root", "storage", "run_root"),
+ ):
+ if field in t.get(section, {}) and other_field not in d.get(other_section, {}):
+ deployment[other_section][other_field] = training[section][field]
+ elif other_field in d.get(other_section, {}) and field not in t.get(section, {}):
+ training[section][field] = deployment[other_section][other_field]
+ elif field in t.get(section, {}) and other_field in d.get(other_section, {}):
+ left, right = training[section][field], deployment[other_section][other_field]
+ if field == "root" and isinstance(left, str) and isinstance(right, str):
+ left, right = ((Path(output_directory) / value).resolve() for value in (left, right))
+ elif field == "advertised_url" and isinstance(left, str) and isinstance(right, str):
+ left, right = left.rstrip("/"), right.rstrip("/")
+ if left != right:
+ raise ValueError(f"training.{section}.{field} conflicts with deployment.{other_section}.{other_field}")
+ count = training["deployment"]["num_gpus"]
+ if "expected_gpus" not in d.get("gpu_target", {}):
+ deployment["gpu_target"]["expected_gpus"] = count
+ if training["deployment"]["layout"] == "colocate":
+ for field in ("actor_gpus", "rollout_gpus"):
+ if field not in t.get("deployment", {}):
+ training["deployment"][field] = count
+ if training["harbor"]["runner_transport"] == "ssh":
+ workers = deployment["rollout"]["workers"]
+ if workers:
+ if "workers" not in t.get("harbor", {}):
+ training["harbor"]["workers"] = [destination(w["ssh"]) for w in workers]
+ if "python" not in t.get("harbor", {}):
+ training["harbor"]["python"] = workers[0]["runner_python"]
+ if answers.get("sandbox_config") is not None:
+ if preset != "service-sandbox-local-runner":
+ raise ValueError("sandbox_config requires the Sandbox preset")
+ environment = training["harbor"]["environment"]
+ if environment.get("config") not in (None, answers["sandbox_config"]):
+ raise ValueError("sandbox_config conflicts with training.harbor.environment.config")
+ environment["config"] = answers["sandbox_config"]
+ required = [("training", section, field) for section, field in (
+ ("tasks", "catalog"), ("harness", "profile"), ("harbor", "python"),
+ ("model", "checkpoint"), ("model", "reference"),
+ ("gateway", "tokenizer_digest"), ("gateway", "template_digest"), ("gateway", "advertised_url"))]
+ if preset != "embedded-local":
+ required += [("deployment", section, field) for section, field in (
+ ("manager", "control_url"), ("manager", "gateway_url"),
+ ("gpu_target", "source_dir"), ("gpu_target", "train_python"))]
+ configs = {"training": training, "deployment": deployment}
+ missing = [".".join(keys) for keys in required if configs[keys[0]][keys[1]][keys[2]] in (None, "")]
+ if preset != "embedded-local" and not deployment["gpu_target"]["ssh"].get("host"):
+ missing.append("deployment.gpu_target.ssh.host")
+ if preset == "service-ssh" and not deployment["rollout"]["workers"]:
+ missing.append("deployment.rollout.workers")
+ if preset == "service-sandbox-local-runner" and not training["harbor"]["environment"].get("config"):
+ missing.append("sandbox_config")
+ if missing:
+ raise ValueError("Missing required answers: " + ", ".join(missing))
+ for field in ("tokenizer_digest", "template_digest"):
+ if training["gateway"][field] == "0" * 64:
+ raise ValueError(f"gateway.{field}: a placeholder digest cannot be used by init")
+ expected_mode = "embedded" if preset == "embedded-local" else "service"
+ expected_provider = {"embedded-local": "embedded", "service-ssh": "ssh_docker",
+ "service-sandbox-local-runner": "sandbox"}[preset]
+ if deployment["manager"]["mode"] != expected_mode or deployment["rollout"]["provider"] != expected_provider:
+ raise ValueError("deployment mode/provider conflicts with the selected preset")
+ # All generated path references are rooted at the final directory, not staging.
+ deployment["storage"]["run_root"] = str((Path(output_directory) / deployment["storage"]["run_root"]).resolve())
+ return training, deployment
+
+
+def worker_defaults():
+ return {"id": "worker-0", "ssh": {"host": None}, "source_dir": str(ROOT),
+ "runner_python": sys.executable, "docker": True, "task_roots": [str(ROOT)]}
diff --git a/harborrl/options/probe.py b/harborrl/options/probe.py
new file mode 100644
index 000000000..12131c5df
--- /dev/null
+++ b/harborrl/options/probe.py
@@ -0,0 +1,39 @@
+"""Explicit, bounded local hints. Never connect to a remote machine."""
+import os
+import shlex
+import subprocess
+import sys
+from pathlib import Path
+
+
+def command_hint(command):
+ try:
+ result = subprocess.run(command, capture_output=True, text=True, timeout=3, check=True)
+ return {"method": command[0], "status": "observed", "value": result.stdout.strip(), "reason": None}
+ except (OSError, subprocess.SubprocessError) as exc:
+ return {"method": command[0], "status": "unknown", "value": None, "reason": str(exc)}
+
+
+def ssh_aliases(path):
+ aliases = set()
+ for line in Path(path).read_text(encoding="utf-8").splitlines():
+ words = shlex.split(line, comments=True)
+ if words and words[0].lower() == "host":
+ aliases.update(word for word in words[1:] if not any(c in word for c in "*?!"))
+ return sorted(aliases)
+
+
+def probe_local(output_directory):
+ try:
+ aliases = {"method": "ssh_config_hosts", "status": "observed",
+ "value": ssh_aliases(Path.home() / ".ssh/config"), "reason": "Include and Match are not evaluated"}
+ except (OSError, ValueError) as exc:
+ aliases = {"method": "ssh_config_hosts", "status": "unknown", "value": None, "reason": str(exc)}
+ parent = Path(output_directory).expanduser().absolute().parent
+ return {
+ "gpu": command_hint(["nvidia-smi", "--query-gpu=uuid", "--format=csv,noheader"]),
+ "python": command_hint([sys.executable, "-I", "--version"]),
+ "ssh_aliases": aliases,
+ "output_parent": {"method": "os.access", "status": "hint", "value": os.access(parent, os.W_OK),
+ "reason": "Access hint only; no write was attempted"},
+ }
diff --git a/harborrl/options/resources/datasets.yaml b/harborrl/options/resources/datasets.yaml
new file mode 100644
index 000000000..40d8e6726
--- /dev/null
+++ b/harborrl/options/resources/datasets.yaml
@@ -0,0 +1,40 @@
+schema_version: 1
+datasets:
+ - name: hello_world
+ status: example
+ source: bundled
+ upstream_revision: v0.1-example
+ license: MIT
+ task_root: examples/native/tasks
+ expected_task_count: 1
+ digest_policy: native-inspector-tree-sha256
+ docker_base: debian:bookworm-slim
+ gpu_required: false
+ acceptance: offline-native-contract-only
+ owner: HarborRL maintainers
+
+ - name: terminal-bench-core
+ status: planned
+ source: https://github.com/laude-institute/terminal-bench
+ upstream_revision: TBD
+ license: TBD
+ task_root: TBD
+ expected_task_count: TBD
+ digest_policy: native-inspector-tree-sha256
+ docker_base: task-defined
+ gpu_required: false
+ acceptance: pending-public-worker-matrix
+ owner: HarborRL maintainers
+
+ - name: terminal-bench-test
+ status: planned
+ source: https://github.com/laude-institute/terminal-bench
+ upstream_revision: dataset/terminal-bench-core/v0.1.x
+ license: TBD
+ task_root: TBD
+ expected_task_count: TBD
+ digest_policy: native-inspector-tree-sha256
+ docker_base: task-defined
+ gpu_required: false
+ acceptance: pending-public-worker-matrix
+ owner: HarborRL maintainers
diff --git a/harborrl/options/validation.py b/harborrl/options/validation.py
new file mode 100644
index 000000000..53465d83e
--- /dev/null
+++ b/harborrl/options/validation.py
@@ -0,0 +1,169 @@
+"""Offline checks composed from the authoritative configuration validators."""
+from copy import deepcopy
+from pathlib import Path
+
+import yaml
+
+from harborrl.config import native
+from harborrl.deployment import schema
+
+
+def read_mapping(path):
+ value = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
+ if not isinstance(value, dict):
+ raise TypeError("configuration must be a mapping")
+ return value
+
+
+def check(path, status, code, message):
+ return {"path": path, "status": status, "code": code, "message": message}
+
+
+def report(checks, *, check_files=False, source="explicit"):
+ states = {item["status"] for item in checks}
+ status = "invalid" if "failed" in states else (
+ "valid_with_deferred_checks" if "deferred" in states else "valid")
+ return {"validation_version": 1, "status": status,
+ "scope": "structure_and_local_files" if check_files else "structure",
+ "deployment_source": source, "checks": checks}
+
+
+def cross_checks(training, deployment):
+ """Only compare settings actually consumed together by the selected mode."""
+ checks = []
+
+ def require(condition, path, message):
+ if not condition:
+ checks.append(check(path, "failed", "inconsistent", message))
+
+ gpu = deployment["gpu_target"]
+ require(training["deployment"]["num_gpus"] <= gpu["expected_gpus"],
+ "deployment.gpu_target.expected_gpus", "Expected GPU count is below the training budget")
+ if deployment["manager"]["mode"] != "service":
+ return checks
+ manager, rollout, harbor = deployment["manager"], deployment["rollout"], training["harbor"]
+ for key, other in (("host", "bind"), ("port", "gateway_port"), ("advertised_url", "gateway_url")):
+ left, right = training["gateway"][key], manager[other]
+ if key == "advertised_url":
+ left, right = left.rstrip("/"), right.rstrip("/")
+ require(left == right, f"training.gateway.{key}", f"Must match deployment.manager.{other}")
+ require(training["output"]["root"] == deployment["storage"]["run_root"],
+ "training.output.root", "Must match deployment.storage.run_root")
+ sandbox = harbor.get("environment", {}).get("runtime") == "sandbox"
+ require(sandbox == (rollout["provider"] == "sandbox"), "deployment.rollout.provider",
+ "Sandbox provider and training environment must agree")
+ workers = rollout["workers"]
+ local = harbor.get("runner_transport", "ssh") == "local"
+ require(not workers if local else bool(workers), "deployment.rollout.workers",
+ "Local runners require no workers; SSH runners require workers")
+ if not local and workers:
+ destinations = [schema.destination(worker["ssh"]) for worker in workers]
+ require(harbor["workers"] == destinations, "training.harbor.workers",
+ "Must match deployment worker destinations in order")
+ require(harbor["python"] == workers[0]["runner_python"], "training.harbor.python",
+ "Must match the deployment's first runner_python")
+ return checks
+
+
+def _local_file(checks, path, label, *, directory=False):
+ target = Path(path)
+ exists = target.is_dir() if directory else target.is_file()
+ checks.append(check(label, "passed" if exists else "failed", "local_path",
+ "Local path exists" if exists else "Required local path is missing"))
+
+
+def _file_checks(training, deployment):
+ checks = []
+ manager = deployment["manager"]
+ _local_file(checks, manager["source_dir"], "deployment.manager.source_dir", directory=True)
+ _local_file(checks, manager["controller_python"], "deployment.manager.controller_python")
+ for label, function in (
+ ("training.harness.profile", lambda: native.validate_files(training)),
+ ("training.tasks.catalog", lambda: native.catalog(training)),
+ ):
+ try:
+ function()
+ except (ValueError, TypeError, KeyError, OSError, AttributeError) as exc:
+ checks.append(check(label, "failed", "local_reference", str(exc)))
+ else:
+ checks.append(check(label, "passed", "local_reference", "Local reference validated"))
+ gpu = deployment["gpu_target"]
+ if gpu["transport"] == "local":
+ for key in ("checkpoint", "reference"):
+ _local_file(checks, training["model"][key], f"training.model.{key}", directory=True)
+ _local_file(checks, gpu["train_python"], "deployment.gpu_target.train_python")
+ _local_file(checks, gpu["source_dir"], "deployment.gpu_target.source_dir", directory=True)
+ if gpu.get("environment_file"):
+ _local_file(checks, gpu["environment_file"], "deployment.gpu_target.environment_file")
+ else:
+ checks.append(check("deployment.gpu_target", "deferred", "remote_paths",
+ "Model, interpreter and source paths belong to the GPU target"))
+ if training["harbor"].get("runner_transport", "ssh") == "local":
+ _local_file(checks, training["harbor"]["python"], "training.harbor.python")
+ else:
+ checks.append(check("deployment.rollout.workers", "deferred", "remote_paths",
+ "Runner paths must be checked on their worker machines"))
+ environment = training["harbor"].get("environment", {})
+ if environment.get("runtime") == "sandbox":
+ from harborrl.rollout.harbor_job.sandbox_config import (
+ load_sandbox,
+ task_binding,
+ )
+ try:
+ cfg = load_sandbox(environment["config"])
+ if cfg["adapter"] == "harborrl.rollout.harbor_job.environments.opensandbox:ManagedOpenSandboxEnvironment":
+ if cfg["options"] or any(binding["options"] for binding in cfg["bindings"]):
+ raise ValueError("standard OpenSandbox does not accept platform options")
+ else:
+ checks.append(check("sandbox.adapter", "deferred", "runner_adapter",
+ "Platform adapter validation belongs to its trusted runner environment"))
+ for entry in native.catalog(training):
+ task_binding(cfg, entry["path"])
+ except (ValueError, TypeError, KeyError, OSError, AttributeError, yaml.YAMLError) as exc:
+ checks.append(check("training.harbor.environment", "failed", "sandbox_config", str(exc)))
+ else:
+ checks.append(check("training.harbor.environment", "passed", "sandbox_config",
+ "Sandbox structure and task image bindings validated; credentials not read"))
+ checks.append(check("sandbox.platform", "deferred", "platform_access",
+ "Credentials, offline binary, capacity and network need runner preflight"))
+ return checks
+
+
+def validate_mappings(training, deployment, *, config_path, check_files=False, source="explicit"):
+ checks = []
+ normalized = {}
+ for name, function in (
+ ("training", lambda: native.validate(deepcopy(training), Path(config_path).resolve())),
+ ("deployment", lambda: schema.validate(deepcopy(deployment))),
+ ):
+ try:
+ normalized[name] = function()
+ except (ValueError, TypeError, KeyError, OSError, AttributeError) as exc:
+ checks.append(check(name, "failed", "schema", str(exc)))
+ else:
+ checks.append(check(name, "passed", "schema", "Schema and semantic checks passed"))
+ if len(normalized) == 2:
+ checks.extend(cross_checks(normalized["training"], normalized["deployment"]))
+ if check_files:
+ checks.extend(_file_checks(normalized["training"], normalized["deployment"]))
+ else:
+ checks.append(check("references", "deferred", "files_not_checked", "Use --check-files for local references"))
+ checks.append(check("runtime", "deferred", "live_preflight",
+ "GPU memory, model loading, live digests and connectivity require doctor or an explicit live test"))
+ return report(checks, check_files=check_files, source=source)
+
+
+def validate_paths(config_path, deployment_path=None, *, check_files=False):
+ try:
+ training = read_mapping(config_path)
+ deployment, source = schema.load_deployment(deployment_path)
+ if source == "embedded-local":
+ normalized = native.validate(deepcopy(training), Path(config_path).resolve())
+ deployment = schema.embedded_local(run_root=normalized["output"]["root"],
+ workers=normalized["harbor"]["workers"], expected_gpus=normalized["deployment"]["num_gpus"])
+ # The legacy validator omits an optional null key from its normalized output.
+ deployment["gpu_target"].setdefault("environment_file", None)
+ return validate_mappings(training, deployment, config_path=config_path,
+ check_files=check_files, source=source)
+ except (ValueError, TypeError, KeyError, OSError, AttributeError, yaml.YAMLError) as exc:
+ return report([check("configuration", "failed", "load", str(exc))], check_files=check_files)
diff --git a/harborrl/options/wizard.py b/harborrl/options/wizard.py
new file mode 100644
index 000000000..55cba4a53
--- /dev/null
+++ b/harborrl/options/wizard.py
@@ -0,0 +1,68 @@
+"""Build a reviewed configuration bundle, then publish it to a new directory."""
+import json
+import os
+import shutil
+import tempfile
+from pathlib import Path
+
+import yaml
+
+from harborrl.config.native import validate
+from harborrl.trajectories.native import digest
+
+from .presets import build_configs
+from .validation import validate_mappings
+
+
+def build_config_bundle(answers, *, output_directory):
+ output = Path(output_directory).expanduser().resolve()
+ training, deployment = build_configs(answers, output)
+ result = validate_mappings(training, deployment, config_path=output / "config.yaml")
+ if result["status"] == "invalid":
+ raise ValueError("; ".join(f"{row['path']}: {row['message']}" for row in result["checks"] if row["status"] == "failed"))
+ training = validate(training, output / "config.yaml")
+ manifest = {"setup_version": 1, "preset": answers["preset"],
+ "schema_versions": {"training": 2, "deployment": 1},
+ "config_digest": digest(training), "deployment_digest": digest(deployment),
+ "references": {"catalog": training["tasks"]["catalog"],
+ "profile": training["harness"]["profile"],
+ "sandbox_config": training["harbor"].get("environment", {}).get("config")},
+ "validation": result}
+ return {"files": {"config.yaml": yaml.safe_dump(training, sort_keys=False),
+ "deployment.yaml": yaml.safe_dump(deployment, sort_keys=False),
+ "setup-manifest.json": json.dumps(manifest, indent=2) + "\n"},
+ "validation": result}
+
+
+def publish_bundle(bundle, output_directory):
+ """No overwrite; same-parent staging and an exclusive per-target lock."""
+ output = Path(output_directory).expanduser().absolute()
+ if output.exists() or output.is_symlink():
+ raise FileExistsError(f"Output directory already exists: {output}")
+ if not output.parent.is_dir():
+ raise ValueError("Output parent directory must already exist")
+ files = bundle["files"]
+ if set(files) != {"config.yaml", "deployment.yaml", "setup-manifest.json"}:
+ raise ValueError("Bundle must contain exactly the three documented files")
+ lock = output.parent / f".{output.name}.init.lock"
+ descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
+ staging = None
+ try:
+ os.close(descriptor)
+ if output.exists() or output.is_symlink():
+ raise FileExistsError(f"Output directory already exists: {output}")
+ staging = Path(tempfile.mkdtemp(prefix=f".{output.name}.init-", dir=output.parent))
+ for name, content in files.items():
+ with (staging / name).open("x", encoding="utf-8") as stream:
+ stream.write(content)
+ stream.flush()
+ os.fsync(stream.fileno())
+ if output.exists() or output.is_symlink():
+ raise FileExistsError(f"Output directory already exists: {output}")
+ staging.rename(output)
+ staging = None
+ finally:
+ if staging is not None:
+ shutil.rmtree(staging)
+ lock.unlink()
+ return output
diff --git a/harborrl/platform/evidence.py b/harborrl/platform/evidence.py
new file mode 100644
index 000000000..d988cf86f
--- /dev/null
+++ b/harborrl/platform/evidence.py
@@ -0,0 +1,126 @@
+"""Run input preparation and reproducible source evidence."""
+import hashlib
+import json
+import subprocess
+import sys
+import time
+from pathlib import Path
+from uuid import uuid4
+
+from harborrl.config import ROOT
+from harborrl.trajectories.native import publish
+
+
+def new_run_root(output_root):
+ name = time.strftime("%Y%m%d-%H%M%S") + "-" + uuid4().hex[:8]
+ return Path(output_root) / "training" / name
+
+
+def run_environment(plan, root, catalog_path, prompt_path):
+ """Shared run paths for both local and controller-managed training."""
+ return {
+ **plan["environment"],
+ "RUN_ID": root.name, "RUN_NAME": root.name, "RUN_DIR": str(root),
+ "RUNS_ROOT": str(root.parent.parent.resolve()),
+ "CKPT_ROOT": str(root / "checkpoints"), "WANDB_DIR": str(root / "metrics/wandb"),
+ "HARBORRL_NATIVE_CATALOG": str(catalog_path),
+ "ROLLOUT_PROMPT_DATA": str(prompt_path),
+ "HARBORRL_NATIVE_PROFILE": plan["config"]["harness"]["profile"],
+ "HARBOR_IR_ROOT": str(root / "ir"),
+ }
+
+
+def _materialize(plan, root):
+ from harborrl.math_registry import select_math
+
+ config = plan["config"]
+ entries = select_math(config).task.adapter.catalog(config)
+ normalized = root / "config"
+ normalized.mkdir(parents=True)
+ catalog_path = normalized / "native_catalog.json"
+ publish(catalog_path, entries)
+ prompt_path = normalized / "native_tasks.jsonl"
+ rows = select_math(config).task.adapter.prompt_rows(entries)
+ prompt_path.write_text("".join(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n" for row in rows))
+ return catalog_path, prompt_path
+
+
+def _source_audit(root):
+ def git(*args):
+ return subprocess.check_output(["git", *args], cwd=ROOT)
+
+ files = git("ls-files", "-z", "--cached", "--others", "--exclude-standard", "--",
+ "harborrl", "scripts", "examples", "pyproject.toml").decode().split("\0")
+ untracked = set(git("ls-files", "-z", "--others", "--exclude-standard", "--",
+ "harborrl", "scripts", "examples").decode().split("\0"))
+ (root / "source.patch").write_bytes(git("diff", "--binary", "HEAD", "--",
+ "harborrl", "scripts", "examples", "pyproject.toml"))
+ hashes = {}
+ for name in files:
+ source = ROOT / name
+ if name and source.is_file():
+ hashes[name] = hashlib.sha256(source.read_bytes()).hexdigest()
+ if name in untracked:
+ target = root / "source-new" / name
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_bytes(source.read_bytes())
+ payload = {
+ "commit": git("rev-parse", "HEAD").decode().strip(),
+ "dirty": bool(git("status", "--porcelain")),
+ "source_hashes": hashes,
+ "patch": "source.patch",
+ }
+ publish(root / "source-manifest.json", payload)
+ return payload
+
+
+
+
+# Proxy values and provider credentials are deliberately outside this snapshot.
+DEPLOYMENT_EVIDENCE_KEYS = ("SLIME_DIR", "MEGATRON_DIR", "SGLANG_IMAGE", "CUDA_VISIBLE_DEVICES")
+
+
+def effective_config(plan, environment, child_env, source, checks):
+ from harborrl.backends.slime_v032.config import field_usage
+ config = plan["config"]
+ return {
+ "schema_version": "harborrl-effective-config-v1",
+ "components": {
+ "model_preset": config["model"]["args_file"],
+ "algorithm": plan["math"]["algorithm"]["id"],
+ "harness": plan["math"]["harness"]["id"],
+ "execution_backend": config["execution"]["backend"],
+ "training_backend_contract": config["training"]["backend_contract"],
+ },
+ "environment": environment,
+ "deployment_environment": {key: child_env[key] for key in DEPLOYMENT_EVIDENCE_KEYS if key in child_env},
+ "training_command": plan["training_command"],
+ "expanded_command_file": "effective-command.json",
+ "source": source,
+ "runtime_versions": [check for check in checks
+ if "version" in check or check.get("service") in
+ ("slime commit", "megatron commit", "sglang image")],
+ "field_usage": field_usage(),
+ "precedence": "YAML < --set in order < explicit MATH selectors; declared deployment environment only",
+ }
+
+
+def record_command(root, command, *, dry_run=False):
+ """Record argv after shell defaults and model preset expansion."""
+ payload = {
+ "schema_version": "harborrl-effective-command-v1",
+ "command": command,
+ "mode": "dry_run" if dry_run else "training",
+ }
+ target = Path(root) / "effective-command.json"
+ if dry_run:
+ checksum = hashlib.sha256(json.dumps(command).encode()).hexdigest()
+ target = Path(root) / "dry-run-commands" / f"{checksum}.json"
+ publish(target, payload)
+
+
+if __name__ == "__main__":
+ # Internal shell boundary: RUN_DIR, mode, then the exact executable argv.
+ if len(sys.argv) < 4 or sys.argv[2] not in ("0", "1"):
+ raise SystemExit("usage: evidence RUN_DIR DRY_RUN COMMAND [ARG ...]")
+ record_command(sys.argv[1], sys.argv[3:], dry_run=sys.argv[2] == "1")
diff --git a/harborrl/platform/native_train.py b/harborrl/platform/native_train.py
index a2a356dbc..0c0eedba2 100644
--- a/harborrl/platform/native_train.py
+++ b/harborrl/platform/native_train.py
@@ -2,249 +2,50 @@
The dispatcher freezes a run directory and materializes the original-task lock
into Slime prompt rows before handing lifecycle control to the existing Slime
-launcher. It never rewrites task tests or rewards.
+launcher. It never rewrites task tests or rewards. A service-mode deployment
+routes through the manager-side controller instead of a local child process.
"""
from __future__ import annotations
-import hashlib
-import importlib
-import importlib.metadata
import json
-import os
-import shutil
-import signal
-import subprocess
import sys
-import time
from pathlib import Path
-from types import SimpleNamespace
-from uuid import uuid4
-from harborrl.config import ROOT
+from harborrl.deployment.training_target import local_target
+from harborrl.platform.evidence import effective_config, new_run_root, run_environment
from harborrl.trajectories.native import publish, read_json
def _run_root(plan):
- name = time.strftime("%Y%m%d-%H%M%S") + "-" + uuid4().hex[:8]
- return Path(plan["config"]["output"]["root"]) / "training" / name
+ return new_run_root(plan["config"]["output"]["root"])
def doctor(plan):
- config = plan["config"]
- checks = []
-
- runner = Path(config["harbor"]["python"])
- checks.append({"path": "harbor.python", "ok": runner.is_file()})
- from harborrl.rollout.harbor_job.coordinator import runner_command
- if runner.is_file():
- for worker in config["harbor"]["workers"]:
- try:
- probe = subprocess.run(
- runner_command(str(runner), ROOT, worker) + ["--probe"],
- capture_output=True, text=True, timeout=30, check=False,
- )
- except (subprocess.SubprocessError, OSError) as exc:
- probe = SimpleNamespace(returncode=1, stdout="", stderr=str(exc))
- try:
- payload = json.loads(probe.stdout)
- runtime_version = tuple(payload.get("python", "").split(".")[:2])
- version_ok = payload.get("harbor_version") == config["harbor"]["version"]
- runtime_ok = len(runtime_version) == 2 and all(part.isdigit() for part in runtime_version)
- except (ValueError, AttributeError, TypeError):
- runtime_version, version_ok, runtime_ok = (), False, False
- checks.append({
- "service": "external Harbor worker",
- "worker": worker,
- "ok": probe.returncode == 0 and runtime_ok
- and tuple(map(int, runtime_version)) >= (3, 12) and version_ok,
- "detail": probe.stdout.strip() or probe.stderr.strip()[-1000:],
- })
-
- for key, path in (("tasks.catalog", config["tasks"]["catalog"]),
- ("harness.profile", config["harness"]["profile"]),
- ("model.checkpoint", config["model"]["checkpoint"]),
- ("model.reference", config["model"]["reference"])):
- checks.append({"path": key, "ok": Path(path).exists()})
-
- model_config = Path(config["model"]["checkpoint"]) / "config.json"
- if model_config.is_file():
- try:
- model = json.loads(model_config.read_text())
- heads = model.get("num_attention_heads", 0)
- kv_heads = model.get("num_key_value_heads", heads)
- for tp in {config["deployment"]["actor_tensor_parallel_size"],
- config["deployment"]["rollout_gpus_per_engine"]}:
- checks.append({"model_tp": tp, "ok": bool(
- heads and kv_heads and heads % tp == 0 and (kv_heads % tp == 0 or tp % kv_heads == 0))})
- except (OSError, ValueError, TypeError) as exc:
- checks.append({"path": "model.config", "ok": False, "detail": str(exc)})
-
- from harborrl.config.native import catalog
- try:
- entries = catalog(config)
- for entry in entries:
- from harborrl.data.harbor.native_inspector import inspect_native
- report = inspect_native(entry["path"], expected_digest=entry["task_digest"])
- checks.append({"task": entry["id"], "ok": report["status"] == "NEEDS_PROBE",
- "detail": report["reasons"]})
- except (ValueError, OSError, TypeError) as exc:
- checks.append({"service": "native catalog", "ok": False, "detail": str(exc)})
-
- for package in ("ray", "torch", "sglang", "transformers"):
- try:
- version = importlib.metadata.version(package)
- except importlib.metadata.PackageNotFoundError:
- version = None
- checks.append({"dependency": package, "version": version, "ok": version is not None})
-
- from harborrl.backends.slime_v032.versions import (
- MEGATRON_COMMIT,
- SGLANG_IMAGE_TAG,
- SLIME_COMMIT,
- )
- slime_dir = os.environ.get("SLIME_DIR", "").strip()
- slime_path = Path(slime_dir) if slime_dir else None
- checks.append({"path": "SLIME_DIR", "ok": bool(slime_path and slime_path.is_dir()),
- "detail": slime_dir or "not set"})
- if slime_path and slime_path.is_dir():
- checks.append({"path": "slime entrypoint", "ok": (slime_path / "train.py").is_file()})
- preset = slime_path / "scripts" / "models" / f"{config['model']['args_file']}.sh"
- checks.append({"path": "slime model preset", "ok": preset.is_file(), "detail": str(preset)})
- try:
- commit = subprocess.run(["git", "-C", str(slime_path), "rev-parse", "HEAD"],
- capture_output=True, text=True, timeout=15, check=True).stdout.strip()
- except (subprocess.SubprocessError, OSError) as exc:
- commit = ""
- checks.append({"service": "slime commit", "ok": False, "detail": str(exc)})
- if commit:
- checks.append({"service": "slime commit", "ok": commit == SLIME_COMMIT,
- "detail": f"{commit} != {SLIME_COMMIT}" if commit != SLIME_COMMIT else commit})
-
- megatron_dir = os.environ.get("MEGATRON_DIR", "").strip()
- if megatron_dir:
- try:
- megatron_commit = subprocess.run(["git", "-C", megatron_dir, "rev-parse", "HEAD"],
- capture_output=True, text=True, timeout=15, check=True).stdout.strip()
- except (subprocess.SubprocessError, OSError) as exc:
- megatron_commit = ""
- checks.append({"service": "megatron commit", "ok": False, "detail": str(exc)})
- if megatron_commit:
- checks.append({"service": "megatron commit", "ok": megatron_commit == MEGATRON_COMMIT,
- "detail": f"{megatron_commit} != {MEGATRON_COMMIT}" if megatron_commit != MEGATRON_COMMIT else megatron_commit})
- else:
- checks.append({"service": "megatron commit",
- "ok": True,
- "detail": "MEGATRON_DIR not set; official Slime image bundles Megatron"})
-
- runtime_env = os.environ.copy()
- runtime_pythonpath = os.pathsep.join(filter(None, [
- str(slime_path), megatron_dir, str(ROOT), runtime_env.get("PYTHONPATH", ""),
- ]))
- runtime_env["PYTHONPATH"] = runtime_pythonpath
- for module in ("slime.rollout.base_types", "slime.utils.types",
- "slime.backends.megatron_utils.loss"):
- try:
- probe = subprocess.run([sys.executable, "-c", f"import {module}"],
- capture_output=True, text=True, timeout=90, check=False,
- env=runtime_env)
- except (subprocess.SubprocessError, OSError) as exc:
- probe = SimpleNamespace(returncode=1, stdout="", stderr=str(exc))
- checks.append({"dependency": module, "ok": probe.returncode == 0,
- "detail": probe.stderr.strip()[-1000:]})
-
- sglang_image = os.environ.get("SGLANG_IMAGE", "").strip()
- if sglang_image:
- checks.append({"service": "sglang image", "ok": sglang_image.endswith(SGLANG_IMAGE_TAG),
- "detail": sglang_image})
-
- if shutil.which("nvidia-smi"):
- try:
- result = subprocess.run(["nvidia-smi", "--query-gpu=index", "--format=csv,noheader"],
- capture_output=True, text=True, timeout=10, check=True)
- visible = os.environ.get("CUDA_VISIBLE_DEVICES")
- count = (len([item for item in visible.split(",") if item.strip() and item.strip() != "-1"])
- if visible is not None else len(result.stdout.splitlines()))
- checks.append({"resource": "visible GPU budget", "available": count,
- "ok": count >= config["deployment"]["num_gpus"]})
- except (subprocess.SubprocessError, OSError) as exc:
- checks.append({"resource": "visible GPU budget", "ok": False, "detail": str(exc)})
- else:
- checks.append({"resource": "visible GPU budget", "ok": False, "detail": "nvidia-smi unavailable"})
-
- if config["training"]["backend_contract"] != "slime-legacy":
- from harborrl.backends.slime_v032.launcher import hook_modules
- for hook_name, module_name in hook_modules().items():
- try:
- importlib.import_module(module_name)
- checks.append({"service": "training backend hook", "hook": hook_name, "ok": True})
- except (ImportError, AttributeError, TypeError, ValueError) as exc:
- checks.append({
- "service": "training backend hook", "hook": hook_name,
- "ok": False, "detail": str(exc),
- })
-
- checks.append({
- "service": "raw-model logprob audit",
- "ok": True,
- "detail": ("enabled by lock; live serving semantic probe remains a GPU gate"
- if config["gateway"]["audited_raw_logprobs"] else
- "disabled; generated evidence remains EVAL_ONLY and training will fail closed"),
- })
- return checks
+ """Compatibility entry point for deployment preflight."""
+ from harborrl.platform.preflight import doctor as check
+ return check(plan)
def _materialize(plan, root):
- from harborrl.config.native import catalog
-
- config = plan["config"]
- entries = catalog(config)
- normalized = root / "config"
- normalized.mkdir(parents=True)
- catalog_path = normalized / "native_catalog.json"
- publish(catalog_path, entries)
- prompt_path = normalized / "native_tasks.jsonl"
- rows = []
- for entry in entries:
- instruction = (Path(entry["path"]) / "instruction.md").read_text()
- rows.append({"task": instruction, "metadata": {"harborrl_native": True, "native_task": entry}})
- prompt_path.write_text("".join(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n" for row in rows))
- return catalog_path, prompt_path
+ from harborrl.platform.evidence import _materialize as prepare
+ return prepare(plan, root)
def _source_audit(root):
- def git(*args):
- return subprocess.check_output(["git", *args], cwd=ROOT)
-
- files = git("ls-files", "-z", "--cached", "--others", "--exclude-standard", "--",
- "harborrl", "scripts", "examples", "pyproject.toml").decode().split("\0")
- untracked = set(git("ls-files", "-z", "--others", "--exclude-standard", "--",
- "harborrl", "scripts", "examples").decode().split("\0"))
- (root / "source.patch").write_bytes(git("diff", "--binary", "HEAD", "--",
- "harborrl", "scripts", "examples", "pyproject.toml"))
- hashes = {}
- for name in files:
- source = ROOT / name
- if name and source.is_file():
- hashes[name] = hashlib.sha256(source.read_bytes()).hexdigest()
- if name in untracked:
- target = root / "source-new" / name
- target.parent.mkdir(parents=True, exist_ok=True)
- target.write_bytes(source.read_bytes())
- payload = {
- "commit": git("rev-parse", "HEAD").decode().strip(),
- "dirty": bool(git("status", "--porcelain")),
- "source_hashes": hashes,
- "patch": "source.patch",
- }
- publish(root / "source-manifest.json", payload)
- return payload
+ from harborrl.platform.evidence import _source_audit as audit
+ return audit(root)
def dispatch(plan, *, doctor_only=False):
+ deployment = (plan.get("deployment") or {}).get("config")
if doctor_only:
checks = doctor(plan)
+ explicit = plan.get("deployment", {}).get("source", "embedded-local") != "embedded-local"
print(json.dumps({"checks": checks,
- "scope": "native config, original tasks, isolated Harbor, training dependencies and GPU budget; protocol/token semantics require the deferred live probe"},
+ "scope": ("deployment topology, " if explicit else "")
+ + "native config, original tasks, isolated Harbor, training "
+ "dependencies and GPU budget; protocol/token semantics "
+ "require the deferred live probe"},
indent=2))
return int(any(not check["ok"] for check in checks))
@@ -254,51 +55,23 @@ def dispatch(plan, *, doctor_only=False):
print(json.dumps({"failed_checks": failed}, indent=2), file=sys.stderr)
return 1
+ if deployment is not None and deployment["manager"]["mode"] == "service":
+ from harborrl.deployment.controller import dispatch_service
+ return dispatch_service(plan, deployment, checks=checks)
+
root = _run_root(plan)
root.mkdir(parents=True)
catalog_path, prompt_path = _materialize(plan, root)
source = _source_audit(root)
- environment = dict(plan["environment"])
- environment.update({
- "RUN_ID": root.name, "RUN_NAME": root.name, "RUN_DIR": str(root),
- "RUNS_ROOT": str(Path(plan["config"]["output"]["root"]).resolve()),
- "CKPT_ROOT": str(root / "checkpoints"), "WANDB_DIR": str(root / "metrics/wandb"),
- "HARBORRL_NATIVE_CATALOG": str(catalog_path),
- "ROLLOUT_PROMPT_DATA": str(prompt_path),
- "HARBORRL_NATIVE_PROFILE": plan["config"]["harness"]["profile"],
- "HARBOR_IR_ROOT": str(root / "ir"),
- })
+ environment = run_environment(plan, root, catalog_path, prompt_path)
launch = {"plan": plan, "run_dir": str(root), "source": source,
"materialized_catalog": str(catalog_path), "prompt_data": str(prompt_path)}
publish(root / "launch.json", launch)
- allowed = {"PATH", "HOME", "USER", "LANG", "LC_ALL", "TMPDIR", "LD_LIBRARY_PATH",
- "CUDA_VISIBLE_DEVICES", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
- "http_proxy", "https_proxy", "no_proxy", "PYTHONPATH",
- "SLIME_DIR", "MEGATRON_DIR", "SGLANG_IMAGE"}
- child_env = {key: value for key, value in os.environ.items() if key in allowed}
- child_env["PATH"] = os.pathsep.join(
- [str(Path(sys.executable).parent), child_env.get("PATH", os.defpath)])
- child_env["PYTHONPATH"] = os.pathsep.join(filter(None, [
- child_env.get("SLIME_DIR", ""), child_env.get("MEGATRON_DIR", ""),
- str(ROOT), child_env.get("PYTHONPATH", ""),
- ]))
- child_env.update(environment)
- child = subprocess.Popen(plan["training_command"], cwd=ROOT, env=child_env, start_new_session=True)
-
- def forward_signal(signum, frame):
- if child.poll() is None:
- try:
- child.send_signal(signum)
- except ProcessLookupError:
- pass
-
- previous = {sig: signal.signal(sig, forward_signal) for sig in (signal.SIGINT, signal.SIGTERM)}
- try:
- return child.wait()
- finally:
- for sig, handler in previous.items():
- signal.signal(sig, handler)
+ target = local_target(plan, environment=environment)
+ publish(root / "effective-config.json", effective_config(plan, environment, target.env, source, checks))
+ target.launch()
+ return target.wait()
if __name__ == "__main__":
diff --git a/harborrl/platform/policy_pool.py b/harborrl/platform/policy_pool.py
index 697ebe2d4..945e9d09c 100644
--- a/harborrl/platform/policy_pool.py
+++ b/harborrl/platform/policy_pool.py
@@ -20,6 +20,8 @@ def validate_pool(states, expected=None):
def publish_pool(states, expected=None):
version = validate_pool(states, expected)
root = Path(os.environ['RUN_DIR'])
+ from harborrl.consistency.sglang import admit_pool
+ admit_pool(states, root)
payload = {'weight_version': version, 'engines': states, 'timestamp': time.time()}
temp = root / 'policy-pool.tmp'
temp.write_text(json.dumps(payload) + '\n')
diff --git a/harborrl/platform/preflight.py b/harborrl/platform/preflight.py
new file mode 100644
index 000000000..3a35fe88f
--- /dev/null
+++ b/harborrl/platform/preflight.py
@@ -0,0 +1,220 @@
+"""Live deployment checks for the native pipeline; no training dispatch.
+
+``doctor`` composes two layers: the deployment topology graph (manager, GPU
+target, rollout workers, control-loop addresses) and the machine-local
+environment checks. The local layer only runs where it is meaningful: when
+the GPU target is remote, dependency probes belong to the GPU job startup,
+not to this machine.
+"""
+import importlib
+import importlib.metadata
+import json
+import os
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+from types import SimpleNamespace
+
+from harborrl.config import ROOT
+
+
+def _deployment(plan):
+ return (plan.get("deployment") or {}).get("config")
+
+
+def doctor(plan):
+ config = plan["config"]
+ checks = []
+ deployment = _deployment(plan)
+ # Embedded-local is a compatibility record, not an opt-in: only an
+ # explicit deployment file activates the topology layers.
+ explicit = deployment is not None and plan.get("deployment", {}).get(
+ "source", "embedded-local") != "embedded-local"
+ local_gpu = deployment is None or deployment["gpu_target"]["transport"] == "local"
+ legacy_workers = (not explicit) or deployment["rollout"]["provider"] in ("embedded", "sandbox")
+
+ if explicit:
+ from harborrl.deployment.preflight import preflight as deployment_preflight
+ checks.extend(deployment_preflight(plan, deployment, live=True))
+ if not local_gpu:
+ checks.append({
+ "role": "gpu_target", "machine": deployment["gpu_target"]["id"],
+ "check": "gpu_target.training_dependencies", "ok": True,
+ "detail": "Slime/Megatron/SGlang versions are enforced fail-closed "
+ "by slime_train.sh at GPU job startup on the GPU machine",
+ })
+
+ runner = Path(config["harbor"]["python"])
+ checks.append({"path": "harbor.python", "ok": runner.is_file()})
+ from harborrl.rollout.harbor_job.coordinator import runner_command
+ if legacy_workers and runner.is_file():
+ workers = ([None] if config['harbor'].get('runner_transport') == 'local'
+ else config['harbor']['workers'])
+ for worker in workers:
+ try:
+ probe = subprocess.run(
+ runner_command(str(runner), ROOT, worker) + ["--probe"],
+ capture_output=True, text=True, timeout=30, check=False,
+ )
+ except (subprocess.SubprocessError, OSError) as exc:
+ probe = SimpleNamespace(returncode=1, stdout="", stderr=str(exc))
+ try:
+ payload = json.loads(probe.stdout)
+ runtime_version = tuple(payload.get("python", "").split(".")[:2])
+ version_ok = payload.get("harbor_version") == config["harbor"]["version"]
+ runtime_ok = len(runtime_version) == 2 and all(part.isdigit() for part in runtime_version)
+ except (ValueError, AttributeError, TypeError):
+ runtime_version, version_ok, runtime_ok = (), False, False
+ checks.append({
+ "service": "local Harbor runner" if worker is None else "external Harbor worker",
+ "worker": worker,
+ "ok": probe.returncode == 0 and runtime_ok
+ and tuple(map(int, runtime_version)) >= (3, 12) and version_ok,
+ "detail": probe.stdout.strip() or probe.stderr.strip()[-1000:],
+ })
+ if 'HARBORRL_NATIVE_SANDBOX' in plan['environment']:
+ for worker in workers:
+ try:
+ probe = subprocess.run(
+ runner_command(str(runner), ROOT, worker,
+ module='harborrl.rollout.harbor_job.sandbox_probe') + ['--stdin'],
+ input=plan['environment']['HARBORRL_NATIVE_SANDBOX'], cwd=ROOT,
+ capture_output=True, text=True, timeout=60, check=False)
+ checks.append({'service': 'sandbox offline runtime', 'worker': worker,
+ 'ok': probe.returncode == 0, 'detail': probe.stdout.strip()})
+ except (subprocess.SubprocessError, OSError) as exc:
+ checks.append({'service': 'sandbox offline runtime', 'worker': worker,
+ 'ok': False, 'detail': type(exc).__name__})
+
+ for key, path in (("tasks.catalog", config["tasks"]["catalog"]),
+ ("harness.profile", config["harness"]["profile"]),
+ ("model.checkpoint", config["model"]["checkpoint"]),
+ ("model.reference", config["model"]["reference"])):
+ checks.append({"path": key, "ok": Path(path).exists()})
+
+ model_config = Path(config["model"]["checkpoint"]) / "config.json"
+ if model_config.is_file():
+ try:
+ model = json.loads(model_config.read_text())
+ heads = model.get("num_attention_heads", 0)
+ kv_heads = model.get("num_key_value_heads", heads)
+ for tp in {config["deployment"]["actor_tensor_parallel_size"],
+ config["deployment"]["rollout_gpus_per_engine"]}:
+ checks.append({"model_tp": tp, "ok": bool(
+ heads and kv_heads and heads % tp == 0 and (kv_heads % tp == 0 or tp % kv_heads == 0))})
+ except (OSError, ValueError, TypeError) as exc:
+ checks.append({"path": "model.config", "ok": False, "detail": str(exc)})
+
+ from harborrl.tasks.catalog import catalog
+ try:
+ entries = catalog(config)
+ for entry in entries:
+ # catalog() already checks the complete task tree against its lock.
+ checks.append({"task": entry["id"], "ok": True, "detail": []})
+ except (ValueError, OSError, TypeError) as exc:
+ checks.append({"service": "native catalog", "ok": False, "detail": str(exc)})
+
+ if local_gpu:
+ for package in ("ray", "torch", "sglang", "transformers"):
+ try:
+ version = importlib.metadata.version(package)
+ except importlib.metadata.PackageNotFoundError:
+ version = None
+ checks.append({"dependency": package, "version": version, "ok": version is not None})
+
+ from harborrl.backends.slime_v032.versions import (
+ MEGATRON_COMMIT,
+ SGLANG_IMAGE_TAG,
+ SLIME_COMMIT,
+ )
+ if local_gpu:
+ slime_dir = os.environ.get("SLIME_DIR", "").strip()
+ slime_path = Path(slime_dir) if slime_dir else None
+ checks.append({"path": "SLIME_DIR", "ok": bool(slime_path and slime_path.is_dir()),
+ "detail": slime_dir or "not set"})
+ if slime_path and slime_path.is_dir():
+ checks.append({"path": "slime entrypoint", "ok": (slime_path / "train.py").is_file()})
+ preset = slime_path / "scripts" / "models" / f"{config['model']['args_file']}.sh"
+ checks.append({"path": "slime model preset", "ok": preset.is_file(), "detail": str(preset)})
+ try:
+ commit = subprocess.run(["git", "-C", str(slime_path), "rev-parse", "HEAD"],
+ capture_output=True, text=True, timeout=15, check=True).stdout.strip()
+ except (subprocess.SubprocessError, OSError) as exc:
+ commit = ""
+ checks.append({"service": "slime commit", "ok": False, "detail": str(exc)})
+ if commit:
+ checks.append({"service": "slime commit", "ok": commit == SLIME_COMMIT,
+ "detail": f"{commit} != {SLIME_COMMIT}" if commit != SLIME_COMMIT else commit})
+
+ megatron_dir = os.environ.get("MEGATRON_DIR", "").strip()
+ if megatron_dir:
+ try:
+ megatron_commit = subprocess.run(["git", "-C", megatron_dir, "rev-parse", "HEAD"],
+ capture_output=True, text=True, timeout=15, check=True).stdout.strip()
+ except (subprocess.SubprocessError, OSError) as exc:
+ megatron_commit = ""
+ checks.append({"service": "megatron commit", "ok": False, "detail": str(exc)})
+ if megatron_commit:
+ checks.append({"service": "megatron commit", "ok": megatron_commit == MEGATRON_COMMIT,
+ "detail": f"{megatron_commit} != {MEGATRON_COMMIT}" if megatron_commit != MEGATRON_COMMIT else megatron_commit})
+ else:
+ checks.append({"service": "megatron commit",
+ "ok": True,
+ "detail": "MEGATRON_DIR not set; official Slime image bundles Megatron"})
+
+ runtime_env = os.environ.copy()
+ runtime_pythonpath = os.pathsep.join(filter(None, [
+ str(slime_path), megatron_dir, str(ROOT), runtime_env.get("PYTHONPATH", ""),
+ ]))
+ runtime_env["PYTHONPATH"] = runtime_pythonpath
+ for module in ("slime.rollout.base_types", "slime.utils.types",
+ "slime.backends.megatron_utils.loss"):
+ try:
+ probe = subprocess.run([sys.executable, "-c", f"import {module}"],
+ capture_output=True, text=True, timeout=90, check=False,
+ env=runtime_env)
+ except (subprocess.SubprocessError, OSError) as exc:
+ probe = SimpleNamespace(returncode=1, stdout="", stderr=str(exc))
+ checks.append({"dependency": module, "ok": probe.returncode == 0,
+ "detail": probe.stderr.strip()[-1000:]})
+
+ sglang_image = os.environ.get("SGLANG_IMAGE", "").strip()
+ if sglang_image:
+ checks.append({"service": "sglang image", "ok": sglang_image.endswith(SGLANG_IMAGE_TAG),
+ "detail": sglang_image})
+
+ if shutil.which("nvidia-smi"):
+ try:
+ result = subprocess.run(["nvidia-smi", "--query-gpu=index", "--format=csv,noheader"],
+ capture_output=True, text=True, timeout=10, check=True)
+ visible = os.environ.get("CUDA_VISIBLE_DEVICES")
+ count = (len([item for item in visible.split(",") if item.strip() and item.strip() != "-1"])
+ if visible is not None else len(result.stdout.splitlines()))
+ checks.append({"resource": "visible GPU budget", "available": count,
+ "ok": count >= config["deployment"]["num_gpus"]})
+ except (subprocess.SubprocessError, OSError) as exc:
+ checks.append({"resource": "visible GPU budget", "ok": False, "detail": str(exc)})
+ else:
+ checks.append({"resource": "visible GPU budget", "ok": False, "detail": "nvidia-smi unavailable"})
+
+ if config["training"]["backend_contract"] != "slime-legacy":
+ from harborrl.backends.slime_v032.launcher import hook_modules
+ for hook_name, module_name in hook_modules().items():
+ try:
+ importlib.import_module(module_name)
+ checks.append({"service": "training backend hook", "hook": hook_name, "ok": True})
+ except (ImportError, AttributeError, TypeError, ValueError) as exc:
+ checks.append({
+ "service": "training backend hook", "hook": hook_name,
+ "ok": False, "detail": str(exc),
+ })
+
+ checks.append({
+ "service": "raw-model logprob audit",
+ "ok": True,
+ "detail": ("enabled by lock; live serving semantic probe remains a GPU gate"
+ if config["gateway"]["audited_raw_logprobs"] else
+ "disabled; generated evidence remains EVAL_ONLY and training will fail closed"),
+ })
+ return checks
diff --git a/harborrl/platform/slime_train.sh b/harborrl/platform/slime_train.sh
index 50377dbab..8d7972874 100644
--- a/harborrl/platform/slime_train.sh
+++ b/harborrl/platform/slime_train.sh
@@ -171,12 +171,19 @@ TRAIN_ARGS=(
export CUDA_DEVICE_MAX_CONNECTIONS=1
TRAIN_PYTHON="${TRAIN_PYTHON:-python3}"
export PYTHONPATH="${REPO_ROOT}${PYTHONPATH:+:${PYTHONPATH}}"
+ENTRY=("${SLIME_DIR}/train.py")
+if [[ "${HARBORRL_CONSISTENCY_MODE:-off}" != "off" ]]; then
+ ENTRY=(-m harborrl.consistency.train)
+fi
+
+"${TRAIN_PYTHON}" -m harborrl.platform.evidence "${RUN_DIR}" "${DRY_RUN:-0}" \
+ "${TRAIN_PYTHON}" -u "${ENTRY[@]}" "${TRAIN_ARGS[@]}"
if [[ "${DRY_RUN:-0}" == "1" ]]; then
printf '[dry-run] '
- printf '%q ' "${TRAIN_PYTHON}" -u "${SLIME_DIR}/train.py" "${TRAIN_ARGS[@]}"
+ printf '%q ' "${TRAIN_PYTHON}" -u "${ENTRY[@]}" "${TRAIN_ARGS[@]}"
printf '\n'
exit 0
fi
-exec "${TRAIN_PYTHON}" -u "${SLIME_DIR}/train.py" "${TRAIN_ARGS[@]}"
+exec "${TRAIN_PYTHON}" -u "${ENTRY[@]}" "${TRAIN_ARGS[@]}"
diff --git a/harborrl/rollout/exporters/native.py b/harborrl/rollout/exporters/native.py
index 55355c7aa..05fb63be8 100644
--- a/harborrl/rollout/exporters/native.py
+++ b/harborrl/rollout/exporters/native.py
@@ -1,47 +1,36 @@
-"""Trajectory-aware GRPO export, independent of Slime/Torch imports.
+"""Compatibility record view of the canonical trajectory-weighted GRPO batch.
-These records must enter a backend that honors token_weights and advantages.
-They must NOT be passed through the interactive reward/whitening builder.
+No independent advantage calculation or secondary whitening occurs here.
"""
-import math
-
-from harborrl.trajectories.native import Identity, finite, require_ready
+from harborrl.export.native import export_training_batch
+from harborrl.export.validate import token_weight
+from harborrl.trajectories.native import Identity
def export_group(trajectories, group_size, *, epsilon=1e-6, allow_synthetic=False):
if type(group_size) is not int or group_size < 2 or len(trajectories) != group_size:
raise ValueError("a complete group of at least two trajectories is required")
- if not finite(epsilon) or epsilon <= 0:
- raise ValueError("epsilon must be positive and finite")
- for ir in trajectories:
- require_ready(ir, allow_synthetic=allow_synthetic)
- ids = [Identity(**ir["identity"]) for ir in trajectories]
- if len({i.group_key for i in ids}) != 1:
- raise ValueError("mixed group, policy, task or profiles")
- for key in ("slot_id", "attempt_id", "trajectory_id"):
- if len({getattr(i, key) for i in ids}) != group_size:
- raise ValueError(f"duplicate {key}")
- rewards = [ir["evaluation"]["training_reward"] for ir in trajectories]
- mean = math.fsum(rewards) / group_size
- std = math.sqrt(math.fsum((r - mean) ** 2 for r in rewards) / group_size)
- if not finite(mean) or not finite(std):
- raise ValueError("nonfinite group statistics")
- advantages = [0.0 if std == 0 else (r - mean) / (std + epsilon) for r in rewards]
+ batch = export_training_batch(
+ [trajectories], group_size=group_size, epsilon=epsilon,
+ allow_synthetic=allow_synthetic,
+ )
+ group = batch.groups[0]
records = []
- for ir, ident, advantage in zip(trajectories, ids, advantages):
- count = sum(len(t["output_ids"]) for t in ir["turns"])
- for index, turn in enumerate(ir["turns"]):
- n = len(turn["output_ids"])
- records.append({"identity": ident.to_dict(), "turn_index": index,
- "tokens": turn["input_ids"] + turn["output_ids"],
- "prompt_length": len(turn["input_ids"]), "response_length": n,
- "loss_mask": [0] * len(turn["input_ids"]) + [1] * n,
- "old_logprobs": turn["logprobs"], "advantages": [advantage] * n,
- "token_weights": [1.0 / (group_size * count)] * n,
- "training_reward": ir["evaluation"]["training_reward"],
- "padding": False})
- return {"export_version": "native-grpo-v2", "group_key": ids[0].group_key,
- "trajectory_count": group_size, "mean": mean, "std": std,
- "advantages": advantages, "records": records,
+ for ir, unit in zip(trajectories, group.trajectories):
+ ident = Identity(**ir["identity"])
+ weight = token_weight(group_size, unit.token_count)
+ for span in unit.turns:
+ n = span.response_length
+ records.append({
+ "identity": ident.to_dict(), "turn_index": span.turn_index,
+ "tokens": list(span.tokens), "prompt_length": span.prompt_length,
+ "response_length": n, "loss_mask": [int(flag) for flag in span.loss_mask],
+ "old_logprobs": list(span.old_logprobs), "advantages": [unit.advantage] * n,
+ "token_weights": [weight] * n, "training_reward": ir["evaluation"]["training_reward"],
+ "padding": False,
+ })
+ return {"export_version": "native-grpo-v2", "group_key": group.group_key,
+ "trajectory_count": group_size, "mean": group.reward_mean, "std": group.reward_std,
+ "advantages": [unit.advantage for unit in group.trajectories], "records": records,
"synthetic": allow_synthetic,
"reduction": "sum_token_weighted_loss; no secondary whitening"}
diff --git a/harborrl/rollout/harbor_job/__main__.py b/harborrl/rollout/harbor_job/__main__.py
index 01828fd19..da10c1bb6 100644
--- a/harborrl/rollout/harbor_job/__main__.py
+++ b/harborrl/rollout/harbor_job/__main__.py
@@ -13,8 +13,10 @@ def main(argv=None):
smoke.add_argument("--output", required=True)
args = parser.parse_args(argv)
if args.command == "inspect":
- from harborrl.data.harbor.native_inspector import inspect_native
- result = inspect_native(args.task, expected_digest=args.expected_digest)
+ from harborrl.math_registry import NATIVE_TASK_PROVIDER
+ result = NATIVE_TASK_PROVIDER.adapter.inspect(
+ args.task, expected_digest=args.expected_digest
+ )
print(json.dumps(result, indent=2))
return int(result["status"] in ("INVALID", "UNSUPPORTED"))
from .offline import smoke as run_smoke
diff --git a/harborrl/rollout/harbor_job/bindings.py b/harborrl/rollout/harbor_job/bindings.py
index 4cd439689..e0e553e85 100644
--- a/harborrl/rollout/harbor_job/bindings.py
+++ b/harborrl/rollout/harbor_job/bindings.py
@@ -5,8 +5,9 @@
def claude_config(profile, gateway_url, credential, max_output_tokens=8192):
required = {"cli_version", "model", "max_turns", "agent_timeout_sec", "setup_timeout_sec", "verifier_timeout_sec"}
- if set(profile) != required:
- raise ValueError(f"Claude profile requires exactly {sorted(required)}")
+ allowed = required | {"disallowed_tools"}
+ if not required.issubset(profile) or set(profile) - allowed:
+ raise ValueError(f"Claude profile requires {sorted(required)} and optionally disallowed_tools")
if not isinstance(profile["cli_version"], str) or not re.fullmatch(r"\d+\.\d+\.\d+(?:-[A-Za-z0-9.]+)?", profile["cli_version"]):
raise ValueError("exact Claude CLI version required; latest is forbidden")
if not isinstance(profile["model"], str) or not re.fullmatch(r"[A-Za-z0-9_.:/-]+", profile["model"]):
@@ -14,6 +15,10 @@ def claude_config(profile, gateway_url, credential, max_output_tokens=8192):
for key in required - {"model", "cli_version"}:
if type(profile[key]) is not int or profile[key] <= 0:
raise ValueError(f"positive integer required: {key}")
+ disallowed_tools = profile.get("disallowed_tools", "Agent,Task")
+ if not isinstance(disallowed_tools, str) or not re.fullmatch(
+ r"[A-Za-z][A-Za-z0-9_]*(?:,[A-Za-z][A-Za-z0-9_]*)*", disallowed_tools):
+ raise ValueError("disallowed tools must be a comma-separated identifier list")
url = urlsplit(gateway_url)
if url.scheme not in ("http", "https") or not url.hostname or url.username or url.password or url.query or url.fragment or url.path not in ("", "/"):
raise ValueError("Anthropic base URL must be a plain origin (no /v1 suffix)")
@@ -27,9 +32,10 @@ def claude_config(profile, gateway_url, credential, max_output_tokens=8192):
"override_timeout_sec": profile["agent_timeout_sec"],
"override_setup_timeout_sec": profile["setup_timeout_sec"],
"kwargs": {"version": profile["cli_version"], "max_turns": profile["max_turns"],
- "max_thinking_tokens": 0, "disallowed_tools": "Agent,Task",
+ "max_thinking_tokens": 0, "disallowed_tools": disallowed_tools,
"permission_mode": "bypassPermissions"},
"env": {**client_controls(), "ANTHROPIC_API_KEY": credential,
+ "ANTHROPIC_MODEL": profile["model"],
"ANTHROPIC_BASE_URL": gateway_url.rstrip("/"),
"NO_PROXY": url.hostname, "no_proxy": url.hostname,
"CLAUDE_CODE_MAX_OUTPUT_TOKENS": str(max_output_tokens)}}
diff --git a/harborrl/rollout/harbor_job/coordinator.py b/harborrl/rollout/harbor_job/coordinator.py
index 4afa6a27d..17047cd40 100644
--- a/harborrl/rollout/harbor_job/coordinator.py
+++ b/harborrl/rollout/harbor_job/coordinator.py
@@ -12,10 +12,9 @@
from dataclasses import replace
from pathlib import Path
+from harborrl.math_registry import CLAUDE_CODE_HARNESS
from harborrl.trajectories.native import assemble, publish
-from .audit import audit_session
-from .bindings import sanitized_runner_env
from .collector import collect
@@ -66,8 +65,7 @@ def export(self):
return export_group([self.completed[slot] for slot in self.identities], len(self.identities))
-def runner_command(runner_python, project, runner_host=None):
- module = "harborrl.rollout.harbor_job.runner"
+def runner_command(runner_python, project, runner_host=None, *, module="harborrl.rollout.harbor_job.runner"):
if runner_host is None:
return [runner_python, "-m", module]
remote = f"cd {shlex.quote(str(project))} && exec {shlex.quote(runner_python)} -m {module}"
@@ -77,13 +75,13 @@ def runner_command(runner_python, project, runner_host=None):
async def run_attempt(identity, *, task_path, profile, gateway_url, runner_python,
root, registry, reward_profile, timeout=1800, cleanup_timeout=120,
- max_output_tokens=8192, runner_host=None):
+ max_output_tokens=8192, runner_host=None, sandbox=None):
"""One real trial. CPU-only tests use a separate fake worker, never this path."""
root = Path(root).resolve()
root.mkdir(parents=True, exist_ok=False)
credential = secrets.token_urlsafe(32)
project = str(Path(__file__).resolve().parents[3])
- env = sanitized_runner_env(os.environ)
+ env = CLAUDE_CODE_HARNESS.adapter.runner_environment(os.environ)
env["PYTHONPATH"] = project
stderr = (root / "runner.stderr").open("wb")
try:
@@ -100,6 +98,12 @@ async def run_attempt(identity, *, task_path, profile, gateway_url, runner_pytho
req = {"identity": identity.to_dict(), "task_path": str(Path(task_path).resolve()), "profile": profile,
"gateway_url": gateway_url, "credential": credential, "output": str(root / "runner"),
"max_output_tokens": max_output_tokens}
+ if sandbox is not None:
+ from harborrl.trajectories.native import digest
+ req.update(sandbox=sandbox, runtime_digest=digest(sandbox))
+ timeout = (sandbox['create_timeout_s'] + sandbox['cleanup_timeout_s'] * 2
+ + sum(profile[k] for k in ('setup_timeout_sec', 'agent_timeout_sec', 'verifier_timeout_sec')))
+ cleanup_timeout = sandbox['cleanup_timeout_s'] + 15
process.stdin.write(json.dumps(req).encode() + b"\n")
await process.stdin.drain()
@@ -133,16 +137,26 @@ async def lifecycle():
await asyncio.wait_for(process.wait(), cleanup_timeout)
if process.returncode != 0 or terminal["type"] != "terminal":
raise RuntimeError("native trial did not finish normally")
+ if sandbox is not None:
+ from harborrl.trajectories.native import read_json
+ lifecycle_path = root / 'runner' / 'sandbox-lifecycle.json'
+ lifecycle = read_json(lifecycle_path) if lifecycle_path.is_file() else {}
+ if lifecycle.get('state') != 'deleted' or lifecycle.get('runtime_digest') != req['runtime_digest']:
+ publish(root / 'cleanup-required.json', {'identity': identity.to_dict(), 'reason': 'Sandbox deletion unconfirmed'})
+ raise RuntimeError('sandbox deletion unconfirmed')
trial_dir = Path(terminal["trial_dir"]).resolve()
result_path = Path(terminal["result_path"]).resolve()
if not trial_dir.is_relative_to(root / "runner" / "harbor") or result_path != trial_dir / "result.json":
raise ValueError("Runner result path is outside this attempt")
- sessions = [p for p in (trial_dir / "agent" / "sessions" / "projects").rglob("*.jsonl")]
+ sessions = list((trial_dir / "agent" / "sessions" / "projects").rglob("*.jsonl"))
if len(sessions) != 1:
raise ValueError("expected exactly one native Claude session; auxiliary sessions require review")
- audit_session(sessions[0], registry, identity.attempt_id)
+ registry.wait_delivered(identity.attempt_id, 30.0)
+ audit = CLAUDE_CODE_HARNESS.adapter.audit_session(sessions[0], registry, identity.attempt_id)
+ receipt = collect(identity, trial_id, result_path, trial_dir / "verifier", reward_profile)
+ CLAUDE_CODE_HARNESS.adapter.audit_empty_final(
+ trial_dir / "agent" / "claude-code.txt", registry, identity.attempt_id, audit)
turns, seal = registry.seal(identity.attempt_id)
- receipt = collect(identity, trial_id, terminal["result_path"], trial_dir / "verifier", reward_profile)
publish(root / "evaluation.json", receipt)
ir = assemble(identity, trial_id, turns, seal, receipt)
publish(root / "trajectory.v2.json", ir)
@@ -152,6 +166,8 @@ async def lifecycle():
publish(root / "failure.json", {"identity": identity.to_dict(), "error_type": type(exc).__name__})
raise
finally:
+ if sandbox is not None and (root / 'runner' / 'cleanup-required.json').exists():
+ publish(root / 'cleanup-required.json', {'identity': identity.to_dict(), 'reason': 'Inspect runner/cleanup-required.json'})
if registered:
registry.close(identity.attempt_id)
if process.returncode is None:
diff --git a/harborrl/rollout/harbor_job/environments/__init__.py b/harborrl/rollout/harbor_job/environments/__init__.py
new file mode 100644
index 000000000..d73e6b751
--- /dev/null
+++ b/harborrl/rollout/harbor_job/environments/__init__.py
@@ -0,0 +1 @@
+"""Optional Harbor environment adapters loaded only by the isolated runner."""
diff --git a/harborrl/rollout/harbor_job/environments/opensandbox.py b/harborrl/rollout/harbor_job/environments/opensandbox.py
new file mode 100644
index 000000000..829f9dadc
--- /dev/null
+++ b/harborrl/rollout/harbor_job/environments/opensandbox.py
@@ -0,0 +1,189 @@
+"""Audited lifecycle around Harbor's OpenSandbox environment."""
+import asyncio
+import fcntl
+import json
+import os
+import tempfile
+import time
+from contextlib import asynccontextmanager
+from datetime import timedelta
+from pathlib import Path
+
+import httpx
+from harbor.environments.opensandbox import OpenSandboxEnvironment
+
+from harborrl.rollout.harbor_job.sandbox_config import read_credential
+from harborrl.trajectories.native import digest, publish
+
+
+@asynccontextmanager
+async def startup_slot(directory, count):
+ """Process-shared creation slots; kernel releases locks on runner death."""
+ directory = Path(directory)
+ directory.mkdir(parents=True, exist_ok=True)
+ held = None
+ try:
+ while held is None:
+ for index in range(count):
+ handle = (directory / f'{index}.lock').open('a')
+ try:
+ fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ held = handle
+ break
+ except BlockingIOError:
+ handle.close()
+ if held is None:
+ await asyncio.sleep(0.2)
+ yield
+ finally:
+ if held is not None:
+ held.close()
+
+
+class ManagedOpenSandboxEnvironment(OpenSandboxEnvironment):
+ @classmethod
+ def validate_config(cls, cfg):
+ if cfg['options'] or any(b['options'] for b in cfg['bindings']):
+ raise ValueError('standard OpenSandbox adapter does not accept platform options')
+
+ @classmethod
+ def agent_gateway(cls, cfg, gateway_url):
+ return gateway_url
+
+ @classmethod
+ async def network_check(cls, cfg):
+ return {'status': 'not_probed', 'reason': 'Use the explicit sandbox smoke test for allocation checks'}
+
+ async def validate_binding(self):
+ pass
+
+ def extensions(self):
+ return {}
+
+ def api_params(self):
+ return {}
+
+ def startup_transport(self, allocated):
+ from harborrl.rollout.harbor_job.sandbox_transport import (
+ SandboxStartupTransport,
+ )
+ return SandboxStartupTransport(self.cfg['domain'].rstrip('/') + '/v1', allocated)
+
+ def __init__(self, *args, sandbox_config, binding, evidence_dir, startup_dir, gateway_url=None, **kwargs):
+ self.validate_config(sandbox_config)
+ self.cfg, self.binding = sandbox_config, binding
+ self.evidence = Path(evidence_dir)
+ self.startup_dir = startup_dir
+ self.gateway_url = gateway_url
+ self._startup_transport = None
+ self.record = {'runtime_digest': digest(sandbox_config),
+ 'image': binding['image'], 'sandbox_id': None, 'state': 'pending', 'events': []}
+ super().__init__(*args, domain=self.cfg['domain'], api_key=read_credential(self.cfg),
+ protocol=self.cfg['protocol'], use_server_proxy=True,
+ ready_timeout_sec=self.cfg['create_timeout_s'],
+ sandbox_timeout_sec=self.cfg['lifecycle_minutes'] * 60,
+ request_timeout_sec=self.cfg['request_timeout_s'], **kwargs)
+
+ def record_state(self, state):
+ self.record['state'] = state
+ self.record['events'].append({'state': state, 'time': time.time()})
+ publish(self.evidence / 'sandbox-events' / f'{len(self.record["events"]):04d}.json', self.record)
+ with tempfile.NamedTemporaryFile(mode='w', dir=self.evidence, delete=False) as stream:
+ json.dump(self.record, stream)
+ stream.flush()
+ os.fsync(stream.fileno())
+ os.replace(stream.name, self.evidence / 'sandbox-lifecycle.json')
+
+ def cleanup_required(self, reason):
+ publish(self.evidence / 'cleanup-required.json', {
+ 'sandbox_id': self.record['sandbox_id'], 'image': self.binding['image'],
+ 'reason': reason, 'runtime_digest': self.record['runtime_digest']})
+
+ async def _create_sandbox(self, sdk):
+ await self.validate_binding()
+ async with startup_slot(self.startup_dir, self.cfg['startup_concurrency']):
+ self.record_state('creating')
+ def allocated(sandbox_id):
+ self.record['sandbox_id'] = sandbox_id
+ self.record_state('allocated')
+
+ self._startup_transport = self.startup_transport(allocated)
+ connection = self._build_connection_config(sdk).model_copy(update={'transport': self._startup_transport})
+ try:
+ sandbox = await asyncio.wait_for(sdk['Sandbox'].create(
+ self.binding['image'], connection_config=connection,
+ timeout=timedelta(minutes=self.cfg['lifecycle_minutes']),
+ ready_timeout=timedelta(seconds=self.cfg['create_timeout_s']),
+ extensions=self.extensions(),
+ resource=self._build_resource(), env=dict(self._persistent_env),
+ network_policy=self._build_network_policy(sdk), skip_health_check=True),
+ self.cfg['create_timeout_s'])
+ except BaseException: # noqa: BLE001 - persist unknown allocation on cancellation too
+ if self.record['sandbox_id']:
+ await self._cleanup_allocated()
+ else:
+ self.record_state('creation_unknown')
+ self.cleanup_required('Creation outcome unknown; reconcile platform inventory before retry')
+ await self._startup_transport.aclose()
+ raise RuntimeError('Sandbox creation outcome unknown') from None
+ self._sandbox = sandbox
+ self.record['sandbox_id'] = sandbox.id
+ self.record_state('created')
+ try:
+ await asyncio.wait_for(self._wait_until_ready(sandbox), self.cfg['create_timeout_s'])
+ self.record_state('ready')
+ return sandbox
+ except BaseException: # noqa: BLE001 - cleanup before redacting SDK errors
+ await self.stop(delete=True)
+ raise RuntimeError('Sandbox readiness failed') from None
+
+ async def _cleanup_allocated(self):
+ """The SDK may already have killed an instance whose endpoints failed."""
+ self.record_state('deleting')
+ url = self.cfg['domain'].rstrip('/') + '/v1/sandboxes/' + self.record['sandbox_id']
+ try:
+ async with asyncio.timeout(self.cfg['cleanup_timeout_s']):
+ async with httpx.AsyncClient(timeout=30, headers={'OPEN-SANDBOX-API-KEY': self._api_key},
+ params=self.api_params()) as client:
+ status = await client.get(url)
+ if status.status_code != 404:
+ (await client.delete(url)).raise_for_status()
+ status = await client.get(url)
+ if status.status_code != 404:
+ raise RuntimeError('allocation deletion unconfirmed')
+ self.record_state('deleted')
+ except BaseException: # noqa: BLE001 - retain debt if cleanup is interrupted
+ self.record_state('delete_failed')
+ self.cleanup_required('Allocated Sandbox deletion was not confirmed')
+
+ async def start(self, force_build):
+ try:
+ await super().start(force_build)
+ except BaseException:
+ if self._sandbox is not None:
+ await self.stop(delete=True)
+ raise
+
+ async def stop(self, delete=True):
+ if self._sandbox is None:
+ if self._startup_transport is not None:
+ await self._startup_transport.aclose()
+ return
+ sandbox = self._sandbox
+ self.record_state('deleting')
+ try:
+ await asyncio.wait_for(sandbox.kill(), self.cfg['cleanup_timeout_s'])
+ self.record_state('deleted')
+ (self.evidence / 'cleanup-required.json').unlink(missing_ok=True)
+ self._sandbox = None
+ except BaseException: # noqa: BLE001 - preserve cleanup debt on cancellation
+ self.record_state('delete_failed')
+ self.cleanup_required('Sandbox kill was not acknowledged')
+ raise RuntimeError('Sandbox deletion failed') from None
+ finally:
+ try:
+ await asyncio.wait_for(sandbox.close(), 10)
+ except Exception: # noqa: BLE001 - close errors must not expose SAT headers
+ self.logger.warning('Sandbox SDK connection close failed')
+ if self._startup_transport is not None:
+ await self._startup_transport.aclose()
diff --git a/harborrl/rollout/harbor_job/offline.py b/harborrl/rollout/harbor_job/offline.py
index 7dfa3220e..8f5dea362 100644
--- a/harborrl/rollout/harbor_job/offline.py
+++ b/harborrl/rollout/harbor_job/offline.py
@@ -7,6 +7,7 @@
from harborrl.gateway.server import Gateway, make_server
from harborrl.gateway.trace import TraceRegistry
+from harborrl.math_registry import CLAUDE_CODE_HARNESS
from harborrl.rollout.exporters.native import export_group
from harborrl.trajectories.native import (
Identity,
@@ -17,7 +18,6 @@
require_ready,
)
-from .audit import audit_session
from .collector import collect
@@ -72,7 +72,9 @@ def smoke(output):
events.append({"sessionId": trial, "type": "assistant", "message": message})
session.write_text("".join(json.dumps(e) + "\n" for e in events))
registry.close(identity.attempt_id)
- audit_session(session, registry, identity.attempt_id)
+ CLAUDE_CODE_HARNESS.adapter.audit_session(
+ session, registry, identity.attempt_id
+ )
turns, seal = registry.seal(identity.attempt_id)
publish(directory / "result.json", {"id": trial, "finished_at": "synthetic", "verifier_environment_mode": "shared",
"verifier_result": {"rewards": {"reward": slot}}})
diff --git a/harborrl/rollout/harbor_job/runner.py b/harborrl/rollout/harbor_job/runner.py
index a5b1c9389..7aa23fe4a 100644
--- a/harborrl/rollout/harbor_job/runner.py
+++ b/harborrl/rollout/harbor_job/runner.py
@@ -14,11 +14,9 @@
import sys
from pathlib import Path
-from harborrl.data.harbor.native_inspector import inspect_native
+from harborrl.math_registry import CLAUDE_CODE_HARNESS, NATIVE_TASK_PROVIDER
from harborrl.trajectories.native import Identity, digest, publish
-from .bindings import claude_config
-
def probe():
if sys.version_info < (3, 12):
@@ -43,7 +41,9 @@ async def execute(request, emit, read_command):
identity = Identity(**request["identity"])
if digest(request["profile"]) != identity.harness_digest:
raise ValueError("harness profile differs from attempt manifest")
- inspection = inspect_native(request["task_path"], expected_digest=identity.task_digest)
+ inspection = NATIVE_TASK_PROVIDER.adapter.inspect(
+ request["task_path"], expected_digest=identity.task_digest
+ )
if inspection["status"] != "NEEDS_PROBE":
raise ValueError("native task preflight failed: " + ",".join(inspection["reasons"]))
root = Path(request["output"]).resolve()
@@ -51,12 +51,36 @@ async def execute(request, emit, read_command):
root.mkdir(parents=True, exist_ok=False)
publish(root / "manifest.json", {"identity": identity.to_dict(), "task": inspection,
"profile": request["profile"], "gateway_url": request["gateway_url"]})
+ sandbox = request.get('sandbox')
+ agent_gateway = request['gateway_url']
+ if sandbox is not None:
+ from harborrl.rollout.harbor_job.sandbox_config import environment_class
+ agent_gateway = environment_class(sandbox).agent_gateway(sandbox, agent_gateway)
+ agent = CLAUDE_CODE_HARNESS.adapter.agent_config(
+ request['profile'], agent_gateway, request['credential'], request['max_output_tokens'])
+ environment = {'type': 'docker', 'delete': True}
+ if sandbox is not None:
+ from harborrl.rollout.harbor_job.sandbox_config import task_binding
+ if digest(sandbox) != request.get('runtime_digest'):
+ raise ValueError('sandbox runtime lock mismatch')
+ if sandbox['installation'] and sandbox['installation']['version'] != request['profile']['cli_version']:
+ raise ValueError('offline harness version mismatch')
+ publish(root / 'runtime.json', {'config': sandbox, 'digest': digest(sandbox)})
+ environment = {'type': 'opensandbox', 'delete': True,
+ 'import_path': sandbox['adapter'],
+ 'kwargs': {'sandbox_config': sandbox, 'binding': task_binding(sandbox, request['task_path']),
+ 'evidence_dir': str(root),
+ 'gateway_url': request['gateway_url'],
+ 'startup_dir': str(root.parents[2] / '.sandbox-startup')}}
+ if sandbox['installation']:
+ agent.pop('name')
+ agent['import_path'] = 'harborrl.harnesses.offline_claude:OfflineClaudeCode'
+ agent['kwargs']['installation'] = sandbox['installation']
config = TrialConfig.model_validate({
"task": {"path": inspection["path"]},
"trial_name": digest(identity.to_dict()), "trials_dir": str(root / "harbor"),
- "agent": claude_config(request["profile"], request["gateway_url"], request["credential"],
- request["max_output_tokens"]),
- "environment": {"type": "docker", "delete": True},
+ "agent": agent,
+ "environment": environment,
"verifier": {"disable": False, "override_timeout_sec": request["profile"]["verifier_timeout_sec"]}})
trial = await Trial.create(config)
trial_id = str(trial.id)
diff --git a/harborrl/rollout/harbor_job/sandbox_config.py b/harborrl/rollout/harbor_job/sandbox_config.py
new file mode 100644
index 000000000..bb008e184
--- /dev/null
+++ b/harborrl/rollout/harbor_job/sandbox_config.py
@@ -0,0 +1,135 @@
+"""Non-secret Sandbox settings; no SDK imports in the training process."""
+import asyncio
+import copy
+import importlib
+import re
+from contextlib import asynccontextmanager
+from pathlib import Path
+from urllib.parse import urlsplit
+
+import yaml
+
+from harborrl.tasks.content import tomllib
+
+
+def _check_options(value):
+ if isinstance(value, dict):
+ for key, item in value.items():
+ if not isinstance(key, str):
+ raise TypeError('sandbox option names must be strings')
+ name = key.lower().replace('-', '_')
+ if name in {'token', 'credential', 'password', 'secret', 'authorization'} or name.endswith(('_api_key', '_token', '_secret')) or name == 'api_key':
+ raise ValueError('sandbox options must reference credential files, not contain secrets')
+ _check_options(item)
+ elif isinstance(value, list):
+ for item in value:
+ _check_options(item)
+
+
+def load_sandbox(path):
+ path = Path(path)
+ cfg = yaml.safe_load(path.read_text())
+ required = {'domain', 'credential_file', 'bindings'}
+ defaults = {'protocol': 'https', 'use_server_proxy': True,
+ 'request_timeout_s': 60, 'create_timeout_s': 600, 'lifecycle_minutes': 120,
+ 'cleanup_timeout_s': 120, 'max_live_instances': 2, 'startup_concurrency': 1,
+ 'adapter': 'harborrl.rollout.harbor_job.environments.opensandbox:ManagedOpenSandboxEnvironment',
+ 'options': {}, 'installation': None}
+ if not isinstance(cfg, dict) or not required <= set(cfg) or set(cfg) - required - defaults.keys():
+ raise ValueError('invalid sandbox configuration fields')
+ cfg = {**defaults, **copy.deepcopy(cfg)}
+ url = urlsplit(cfg['domain'])
+ if (url.scheme not in ('http', 'https')
+ or not url.hostname or url.username or url.password or url.query or url.fragment
+ or url.path.rstrip('/').endswith('/v1') or cfg['protocol'] != url.scheme):
+ raise ValueError('invalid sandbox domain/protocol')
+ for key in defaults.keys() - {'protocol', 'use_server_proxy', 'adapter', 'options', 'installation'}:
+ if type(cfg[key]) is not int or cfg[key] <= 0:
+ raise ValueError(f'sandbox {key} must be a positive integer')
+ if type(cfg['use_server_proxy']) is not bool or not isinstance(cfg['options'], dict):
+ raise ValueError('invalid sandbox proxy/options')
+ _check_options(cfg['options'])
+ if not isinstance(cfg['adapter'], str) or not re.fullmatch(r'[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*:[A-Za-z_]\w*', cfg['adapter']):
+ raise ValueError('sandbox adapter must be module:Class')
+ if not isinstance(cfg['credential_file'], str) or not cfg['credential_file']:
+ raise ValueError('sandbox credential_file required')
+ cfg['credential_file'] = str((path.parent / cfg['credential_file']).resolve())
+ bindings = cfg['bindings']
+ if not isinstance(bindings, list) or not bindings:
+ raise ValueError('sandbox image bindings required')
+ images = set()
+ for binding in bindings:
+ if not isinstance(binding, dict) or set(binding) - {'image', 'max_live_instances', 'options'} or not {'image', 'max_live_instances'} <= set(binding):
+ raise ValueError('invalid sandbox binding fields')
+ binding.setdefault('options', {})
+ _check_options(binding['options'])
+ if (not isinstance(binding['image'], str) or not binding['image']
+ or binding['image'] in images or not isinstance(binding['options'], dict)):
+ raise ValueError('invalid or duplicate sandbox image binding')
+ if type(binding['max_live_instances']) is not int or binding['max_live_instances'] <= 0:
+ raise ValueError('binding max_live_instances must be positive')
+ images.add(binding['image'])
+ install = cfg['installation']
+ if install is None:
+ return cfg
+ if not isinstance(install, dict) or set(install) != {'mode', 'version', 'binary', 'sha256'}:
+ raise ValueError('installation requires mode, version, binary, sha256')
+ if (install['mode'] != 'offline_binary' or not isinstance(install['version'], str)
+ or not re.fullmatch(r'\d+\.\d+\.\d+', install['version'])
+ or not isinstance(install['sha256'], str) or not re.fullmatch(r'[0-9a-f]{64}', install['sha256'])
+ or not isinstance(install['binary'], str) or not install['binary']):
+ raise ValueError('invalid pinned offline binary installation')
+ install['binary'] = str((path.parent / install['binary']).resolve())
+ return cfg
+
+
+def task_binding(cfg, task_path):
+ spec = tomllib.loads((Path(task_path) / 'task.toml').read_text())
+ image = spec.get('environment', {}).get('docker_image')
+ for binding in cfg['bindings']:
+ if image == binding['image']:
+ return binding
+ raise ValueError('sandbox task requires a prebuilt image with an exact binding')
+
+
+def environment_class(cfg):
+ """Import trusted deployment code only in the runner, never in GPU training."""
+ module, name = cfg['adapter'].split(':')
+ cls = getattr(importlib.import_module(module), name)
+ from harborrl.rollout.harbor_job.environments.opensandbox import (
+ ManagedOpenSandboxEnvironment,
+ )
+ if not isinstance(cls, type) or not issubclass(cls, ManagedOpenSandboxEnvironment):
+ raise TypeError('sandbox adapter must implement the audited OpenSandbox lifecycle')
+ cls.validate_config(cfg)
+ return cls
+
+
+def read_credential(cfg):
+ path = Path(cfg['credential_file'])
+ if path.stat().st_mode & 0o077:
+ raise ValueError('sandbox credential file must have mode 600 or stricter')
+ value = path.read_text().strip()
+ if not value or '\n' in value:
+ raise ValueError('sandbox credential file must contain one nonempty token')
+ return value
+
+
+class SandboxAdmission:
+ """One runtime owns capacity; unresolved cleanup blocks further admission."""
+ def __init__(self, cfg):
+ self.total = asyncio.Semaphore(cfg['max_live_instances'])
+ self.bindings = {b['image']: asyncio.Semaphore(b['max_live_instances']) for b in cfg['bindings']}
+ self.blocked = False
+
+ @asynccontextmanager
+ async def lease(self, binding, root):
+ # Wait for image-specific capacity before reserving a global live slot.
+ async with self.bindings[binding['image']], self.total:
+ if self.blocked:
+ raise RuntimeError('sandbox admission blocked by unresolved cleanup')
+ try:
+ yield
+ finally:
+ if (Path(root) / 'cleanup-required.json').exists():
+ self.blocked = True
diff --git a/harborrl/rollout/harbor_job/sandbox_probe.py b/harborrl/rollout/harbor_job/sandbox_probe.py
new file mode 100644
index 000000000..ebed3f47d
--- /dev/null
+++ b/harborrl/rollout/harbor_job/sandbox_probe.py
@@ -0,0 +1,63 @@
+"""Offline dependency check or explicit live Environment check (no allocation)."""
+import argparse
+import asyncio
+import hashlib
+import importlib.metadata
+import json
+import sys
+from pathlib import Path
+
+from harborrl.rollout.harbor_job.sandbox_config import (
+ environment_class,
+ load_sandbox,
+ read_credential,
+)
+
+
+def check(cfg):
+ from harborrl.rollout.harbor_job.runner import probe
+ result = probe()
+ version = importlib.metadata.version('opensandbox')
+ if version != '0.1.9':
+ raise ValueError('sandbox runner requires opensandbox==0.1.9')
+ from opensandbox.models.execd import RunCommandOpts
+
+ adapter = environment_class(cfg)
+ if not {'timeout', 'envs', 'working_directory'} <= RunCommandOpts.model_fields.keys():
+ raise ValueError('unsupported SDK command options')
+ checksum = None
+ if cfg['installation']:
+ with Path(cfg['installation']['binary']).open('rb') as source:
+ checksum = hashlib.file_digest(source, 'sha256').hexdigest()
+ if checksum != cfg['installation']['sha256']:
+ raise ValueError('offline binary checksum mismatch')
+ read_credential(cfg)
+ result.update(opensandbox_version=version, environment=adapter.__name__, binary_sha256=checksum)
+ return result
+
+
+async def network_check(cfg):
+ return await environment_class(cfg).network_check(cfg)
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('config', nargs='?')
+ parser.add_argument('--stdin', action='store_true')
+ parser.add_argument('--network', action='store_true')
+ args = parser.parse_args()
+ try:
+ cfg = json.load(sys.stdin) if args.stdin else load_sandbox(args.config)
+ result = check(cfg)
+ if args.network:
+ result['environments'] = asyncio.run(network_check(cfg))
+ print(json.dumps(result))
+ except Exception as exc: # noqa: BLE001 - redact authenticated SDK exceptions
+ # SDK/HTTP exceptions may contain authenticated endpoint URLs.
+ print(json.dumps({'ok': False, 'error_type': type(exc).__name__}))
+ return 1
+ return 0
+
+
+if __name__ == '__main__':
+ raise SystemExit(main())
diff --git a/harborrl/rollout/harbor_job/sandbox_transport.py b/harborrl/rollout/harbor_job/sandbox_transport.py
new file mode 100644
index 000000000..385ad5701
--- /dev/null
+++ b/harborrl/rollout/harbor_job/sandbox_transport.py
@@ -0,0 +1,27 @@
+"""Observe SDK allocation IDs without retrying creation requests."""
+import re
+
+import httpx
+
+
+class SandboxStartupTransport(httpx.AsyncBaseTransport):
+ def __init__(self, base_url, on_created, *, transport=None):
+ self.base_url = base_url.rstrip('/')
+ self.on_created = on_created
+ self.transport = transport or httpx.AsyncHTTPTransport()
+
+ async def handle_async_request(self, request):
+ url = str(request.url).split('?', 1)[0]
+ response = await self.transport.handle_async_request(request)
+ if request.method == 'POST' and url == self.base_url + '/sandboxes' and response.is_success:
+ await response.aread()
+ try:
+ sandbox_id = response.json()['id']
+ if isinstance(sandbox_id, str) and re.fullmatch(r'[A-Za-z0-9_-]+', sandbox_id):
+ self.on_created(sandbox_id)
+ except (ValueError, KeyError, TypeError):
+ pass # Let the SDK reject malformed creation responses.
+ return response
+
+ async def aclose(self):
+ await self.transport.aclose()
diff --git a/harborrl/rollout/native_generate.py b/harborrl/rollout/native_generate.py
index 5f59276e7..b5cfeebda 100644
--- a/harborrl/rollout/native_generate.py
+++ b/harborrl/rollout/native_generate.py
@@ -1,255 +1,17 @@
-"""Slime group hook for native Harbor trials.
-
-The hook keeps one trajectory per Slime slot and stores its immutable IR in
-metadata. Turn expansion happens only at the training converter boundary.
-"""
-from __future__ import annotations
-
-import asyncio
-import json
-import os
-import secrets
-from pathlib import Path
-
-from harborrl.rollout.harbor_job.coordinator import GroupSlots, run_attempt
-from harborrl.trajectories.native import Identity, RewardProfile, digest, read_json
-
-
-class NativeRolloutRuntime:
- def __init__(self, args):
- from slime.utils.processing_utils import load_tokenizer
-
- from harborrl.gateway.server import Gateway, make_server
- from harborrl.gateway.sglang_backend import SGLangBackend
- from harborrl.gateway.trace import TraceRegistry
-
- self.args = args
- self.run_root = Path(os.environ["RUN_DIR"]).resolve()
- self.catalog = read_json(os.environ["HARBORRL_NATIVE_CATALOG"])
- self.profile = read_json(os.environ["HARBORRL_NATIVE_PROFILE"])
- self.rollout_root = self.run_root / "native-rollouts"
- self.rollout_root.mkdir(parents=True, exist_ok=True)
- self.registry = TraceRegistry(self.run_root / "native-traces")
- engine_count = int(args.rollout_num_gpus) // int(args.rollout_num_gpus_per_engine)
- limit = max(1, int(args.sglang_server_concurrency) * engine_count)
- self.semaphore = asyncio.Semaphore(limit)
- tokenizer = load_tokenizer(args.hf_checkpoint, trust_remote_code=True)
- tokenizer_digest, template_digest = tokenizer_fingerprint(tokenizer)
- expected_tokenizer = os.environ["HARBORRL_NATIVE_TOKENIZER_DIGEST"]
- expected_template = os.environ["HARBORRL_NATIVE_TEMPLATE_DIGEST"]
- if (tokenizer_digest != expected_tokenizer or template_digest != expected_template):
- raise ValueError("live tokenizer/template digest differs from the native lock")
- endpoint = f"http://{args.sglang_router_ip}:{args.sglang_router_port}"
- backend = SGLangBackend(
- endpoint,
- tokenizer,
- tokenizer_digest=tokenizer_digest,
- template_digest=template_digest,
- max_context=int(args.rollout_max_context_len),
- audited_raw_logprobs=os.environ.get("HARBORRL_NATIVE_AUDITED_LOGPROBS") == "1",
- )
- self.backend = backend
- gateway = Gateway(
- self.registry,
- backend,
- model=self.profile["model"],
- max_output_tokens=int(args.rollout_max_response_len),
- )
- self.server = make_server(
- gateway,
- host=os.environ.get("HARBORRL_NATIVE_GATEWAY_HOST", "0.0.0.0"),
- port=int(os.environ["HARBORRL_NATIVE_GATEWAY_PORT"]),
- )
- self.thread = asyncio.get_running_loop().run_in_executor(None, self.server.serve_forever)
-
- def close(self):
- self.server.shutdown()
- self.server.server_close()
-
-
-def tokenizer_fingerprint(tokenizer):
- messages = [{"role": "user", "content": "HarborRL native tokenizer/template fingerprint"}]
- kwargs = {"tokenize": True, "add_generation_prompt": True, "enable_thinking": False}
- rendered = tokenizer.apply_chat_template(messages, **kwargs)
- template = digest({"messages": messages, "kwargs": kwargs, "token_ids": rendered})
- vocabulary = tokenizer.get_vocab()
- tokenizer_digest = digest({
- "class": type(tokenizer).__name__,
- "vocab": sorted((str(key), int(value)) for key, value in vocabulary.items()),
- })
- return tokenizer_digest, template
-
-
-_RUNTIME = None
-_RUNTIME_LOCK = asyncio.Lock()
-
-
-async def runtime(args):
- global _RUNTIME
- if _RUNTIME is None:
- async with _RUNTIME_LOCK:
- if _RUNTIME is None:
- _RUNTIME = NativeRolloutRuntime(args)
- return _RUNTIME
-
-
-def publish_native_version(states, expected):
- """Open the next admission epoch only after the complete pool is healthy."""
- if _RUNTIME is None:
- return
- _RUNTIME.registry.drain(float(os.environ.get("HARBORRL_NATIVE_DRAIN_TIMEOUT", "30")))
- _RUNTIME.registry.publish_version(states, str(expected))
-
-
-def current_policy_version(runtime_):
- """Publish and lock the version currently served by the rollout pool."""
- states = runtime_.backend.pool_states()
- from harborrl.platform.policy_pool import publish_pool
- version = publish_pool(states)
- runtime_.registry.drain(float(os.environ.get("HARBORRL_NATIVE_DRAIN_TIMEOUT", "30")))
- runtime_.registry.publish_version(states, version)
- return version
-
-
-def close_runtime():
- global _RUNTIME
- if _RUNTIME is not None:
- _RUNTIME.close()
- _RUNTIME = None
-
-
-def _task_entry(sample):
- metadata = sample.metadata if isinstance(sample.metadata, dict) else {}
- entry = metadata.get("native_task")
- required = {"id", "revision", "path", "task_digest", "reward_profile"}
- if not isinstance(entry, dict) or set(entry) != required:
- raise ValueError("native rollout requires a locked task catalog entry")
- return entry
-
-
-def _identity(
- args,
- sample,
- slot,
- rollout_id,
- task_digest,
- reward_digest,
- sampling_digest,
- policy_version,
-):
- group_index = getattr(sample, "group_index", None)
- if type(group_index) is not int:
- raise ValueError("Slime must provide group identity before native generation")
- return Identity(
- run_id=os.environ["RUN_ID"],
- batch_id=f"rollout-{rollout_id}",
- group_id=f"group-{group_index}",
- slot_id=str(slot),
- attempt_id=secrets.token_hex(16),
- trajectory_id=secrets.token_hex(16),
- task_digest=task_digest,
- policy_version=str(policy_version),
- harness_digest=digest(read_json(os.environ["HARBORRL_NATIVE_PROFILE"])),
- reward_digest=reward_digest,
- sampling_digest=sampling_digest,
- )
-
-
-async def _run_slot(
- args,
- runtime_,
- sample,
- slot,
- rollout_id,
- entry,
- sampling_digest,
- policy_version,
-):
- reward_profile = RewardProfile(**entry["reward_profile"])
- identities = [
- _identity(
- args,
- sample,
- index,
- rollout_id=rollout_id,
- task_digest=entry["task_digest"],
- reward_digest=reward_profile.digest,
- sampling_digest=sampling_digest,
- policy_version=policy_version,
- )
- for index in range(args.n_samples_per_prompt)
- ]
- slots = GroupSlots(identities, max_attempts=int(os.environ["HARBORRL_NATIVE_MAX_ATTEMPTS"]))
- workers = json.loads(os.environ["HARBORRL_NATIVE_RUNNER_WORKERS"])
- task_workers = json.loads(os.environ.get("HARBORRL_NATIVE_TASK_WORKER_OVERRIDES", "{}"))
- for attempt_index in range(slots.max_attempts):
- identity = slots.start(str(slot))
- runner_host = task_workers.get(entry["id"])
- if runner_host is None:
- runner_host = workers[(slot + attempt_index) % len(workers)]
- root = runtime_.rollout_root / identity.group_id / identity.attempt_id
- try:
- async with runtime_.semaphore:
- ir = await run_attempt(
- identity,
- task_path=entry["path"],
- profile=runtime_.profile,
- gateway_url=os.environ["HARBORRL_NATIVE_GATEWAY_URL"],
- runner_python=os.environ["HARBORRL_NATIVE_RUNNER_PYTHON"],
- runner_host=runner_host,
- root=root,
- registry=runtime_.registry,
- reward_profile=reward_profile,
- max_output_tokens=int(args.rollout_max_response_len),
- )
- except (OSError, ValueError, RuntimeError, asyncio.TimeoutError) as exc:
- slots.finish(str(slot), error=f"{type(exc).__name__}: {exc}")
- continue
- slots.finish(str(slot), ir)
- return ir
- raise RuntimeError(f"native slot {slot} exhausted its retry budget")
-
-
-async def generate_group(args, group, sampling_params, rollout_id, evaluation=False):
- """Run one complete native group; evaluation is intentionally unsupported."""
- from harborrl.data.harbor.native_inspector import inspect_native
-
- if evaluation:
- raise ValueError("native training rollout cannot be reused as evaluation")
- if len(group) != args.n_samples_per_prompt:
- raise ValueError("native rollout group is incomplete before launch")
- entries = [_task_entry(sample) for sample in group]
- if any(entry != entries[0] for entry in entries[1:]):
- raise ValueError("native rollout group mixes task locks")
- entry = entries[0]
- report = inspect_native(entry["path"], expected_digest=entry["task_digest"])
- if report["status"] != "NEEDS_PROBE":
- raise ValueError(f"native task preflight failed: {report['reasons']}")
- stop = list(sampling_params.get("stop") or [])
- sampling_digest = digest({
- "temperature": sampling_params.get("temperature"),
- "top_p": sampling_params.get("top_p"),
- "top_k": sampling_params.get("top_k"),
- "max_new_tokens": sampling_params.get("max_new_tokens"),
- "stop": stop,
- })
- runtime_ = await runtime(args)
- policy_version = current_policy_version(runtime_)
- trajectories = await asyncio.gather(*[
- _run_slot(
- args, runtime_, sample, slot, rollout_id, entry, sampling_digest, policy_version
- )
- for slot, sample in enumerate(group)
- ])
- output = []
- for sample, ir in zip(group, trajectories):
- sample.metadata["native_ir"] = ir
- sample.response = "native Harbor trial"
- sample.reward = {"score": ir["evaluation"]["training_reward"]}
- sample.status = sample.Status.COMPLETED
- try:
- sample.policy_version = int(ir["identity"]["policy_version"])
- except ValueError as exc:
- raise ValueError("native policy version must be an integer in Slime") from exc
- output.append(sample)
- return output
+"""Compatibility imports for the framework-independent rollout runtime."""
+from harborrl.rollout.runtime import ( # noqa: F401
+ GroupSlots,
+ NativeRolloutRuntime,
+ RolloutRuntime,
+ RolloutRuntimeConfig,
+ close_runtime,
+ current_policy_version,
+ publish_native_version,
+ run_attempt,
+ run_group,
+ run_slot,
+ runtime,
+ slot_identity,
+ task_entry,
+ tokenizer_fingerprint,
+)
diff --git a/harborrl/rollout/runtime.py b/harborrl/rollout/runtime.py
new file mode 100644
index 000000000..558bab01d
--- /dev/null
+++ b/harborrl/rollout/runtime.py
@@ -0,0 +1,350 @@
+"""Extracted rollout core (P2): gateway, traces and complete groups.
+
+This module owns rollout execution and must not import Slime or Ray: training
+frameworks live in ``harborrl.backends.*`` adapters that build a
+``RolloutRuntimeConfig`` and hand over a tokenizer. Group semantics, worker
+scheduling, retries and the RL_READY contract stay exactly as the embedded
+pipeline enforced them.
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+import secrets
+import threading
+from contextlib import nullcontext
+from dataclasses import dataclass
+from pathlib import Path
+
+from harborrl.models.qwen import normalize_chat_template_ids
+from harborrl.rollout.harbor_job.coordinator import GroupSlots, run_attempt
+from harborrl.trajectories.native import Identity, RewardProfile, digest, read_json
+
+TASK_ENTRY_KEYS = {"id", "revision", "path", "task_digest", "reward_profile"}
+
+
+@dataclass(frozen=True)
+class RolloutRuntimeConfig:
+ """Deployment-independent description of one run's rollout machinery."""
+
+ run_id: str
+ run_root: str
+ catalog_path: str
+ profile_path: str
+ gateway_host: str
+ gateway_port: int
+ gateway_url: str
+ runner_python: str
+ workers: tuple
+ task_worker_overrides: dict
+ max_attempts: int
+ max_output_tokens: int
+ max_context: int
+ concurrency_limit: int
+ group_size: int
+ expected_tokenizer_digest: str
+ expected_template_digest: str
+ audited_raw_logprobs: bool
+ drain_timeout: float
+ model_tag: str
+ endpoint: str
+ sandbox: dict = None
+ runner_transport: str = 'ssh'
+
+ @staticmethod
+ def _required(env, name):
+ value = env.get(name)
+ if not isinstance(value, str) or not value.strip():
+ raise ValueError(f"rollout runtime requires {name}")
+ return value
+
+ @classmethod
+ def from_environment(cls, env, *, endpoint, concurrency_limit, group_size,
+ gateway_host=None, gateway_port=None, gateway_url=None,
+ workers=None, runner_python=None):
+ if not isinstance(endpoint, str) or not endpoint.strip():
+ raise ValueError("rollout runtime requires a serving endpoint")
+ if type(group_size) is not int or group_size < 2:
+ raise ValueError("rollout runtime requires a group size of at least two")
+ if workers is None:
+ workers = json.loads(cls._required(env, "HARBORRL_NATIVE_RUNNER_WORKERS"))
+ if runner_python is None:
+ runner_python = cls._required(env, "HARBORRL_NATIVE_RUNNER_PYTHON")
+ try:
+ overrides = json.loads(env.get("HARBORRL_NATIVE_TASK_WORKER_OVERRIDES") or "{}")
+ except ValueError as exc:
+ raise ValueError("invalid HARBORRL_NATIVE_TASK_WORKER_OVERRIDES") from exc
+ return cls(
+ run_id=cls._required(env, "RUN_ID"),
+ run_root=cls._required(env, "RUN_DIR"),
+ catalog_path=cls._required(env, "HARBORRL_NATIVE_CATALOG"),
+ profile_path=cls._required(env, "HARBORRL_NATIVE_PROFILE"),
+ gateway_host=gateway_host or env.get("HARBORRL_NATIVE_GATEWAY_HOST", "0.0.0.0"),
+ gateway_port=int(gateway_port or cls._required(env, "HARBORRL_NATIVE_GATEWAY_PORT")),
+ gateway_url=gateway_url or cls._required(env, "HARBORRL_NATIVE_GATEWAY_URL"),
+ runner_python=runner_python,
+ workers=tuple(workers),
+ task_worker_overrides=dict(overrides),
+ max_attempts=int(env.get("HARBORRL_NATIVE_MAX_ATTEMPTS", "2")),
+ max_output_tokens=int(env.get("ROLLOUT_MAX_RESPONSE_LEN", "8192")),
+ max_context=int(env.get("ROLLOUT_MAX_CONTEXT_LEN", "32768")),
+ concurrency_limit=max(1, int(concurrency_limit)),
+ group_size=int(group_size),
+ expected_tokenizer_digest=cls._required(env, "HARBORRL_NATIVE_TOKENIZER_DIGEST"),
+ expected_template_digest=cls._required(env, "HARBORRL_NATIVE_TEMPLATE_DIGEST"),
+ audited_raw_logprobs=env.get("HARBORRL_NATIVE_AUDITED_LOGPROBS") == "1",
+ drain_timeout=float(env.get("HARBORRL_NATIVE_DRAIN_TIMEOUT", "30")),
+ model_tag=env.get("MODEL_TAG", env.get("MODEL_ARGS_FILE", "qwen3-8B")),
+ endpoint=endpoint.rstrip("/"),
+ sandbox=json.loads(env.get('HARBORRL_NATIVE_SANDBOX', 'null')),
+ runner_transport=env.get('HARBORRL_NATIVE_RUNNER_TRANSPORT', 'ssh'),
+ )
+
+ def environment(self):
+ """Render the environment mapping this config was built from."""
+ return {
+ "RUN_ID": self.run_id, "RUN_DIR": self.run_root,
+ "HARBORRL_NATIVE_CATALOG": self.catalog_path,
+ "HARBORRL_NATIVE_PROFILE": self.profile_path,
+ "HARBORRL_NATIVE_GATEWAY_HOST": self.gateway_host,
+ "HARBORRL_NATIVE_GATEWAY_PORT": str(self.gateway_port),
+ "HARBORRL_NATIVE_GATEWAY_URL": self.gateway_url,
+ "HARBORRL_NATIVE_RUNNER_PYTHON": self.runner_python,
+ "HARBORRL_NATIVE_RUNNER_WORKERS": json.dumps(list(self.workers)),
+ "HARBORRL_NATIVE_TASK_WORKER_OVERRIDES": json.dumps(
+ self.task_worker_overrides, sort_keys=True),
+ "HARBORRL_NATIVE_MAX_ATTEMPTS": str(self.max_attempts),
+ "ROLLOUT_MAX_RESPONSE_LEN": str(self.max_output_tokens),
+ "ROLLOUT_MAX_CONTEXT_LEN": str(self.max_context),
+ "HARBORRL_NATIVE_TOKENIZER_DIGEST": self.expected_tokenizer_digest,
+ "HARBORRL_NATIVE_TEMPLATE_DIGEST": self.expected_template_digest,
+ "HARBORRL_NATIVE_AUDITED_LOGPROBS": str(int(self.audited_raw_logprobs)),
+ "HARBORRL_NATIVE_DRAIN_TIMEOUT": str(self.drain_timeout),
+ "MODEL_TAG": self.model_tag,
+ "HARBORRL_NATIVE_SANDBOX": json.dumps(self.sandbox),
+ "HARBORRL_NATIVE_RUNNER_TRANSPORT": self.runner_transport,
+ }
+
+
+def tokenizer_fingerprint(tokenizer):
+ messages = [{"role": "user", "content": "HarborRL native tokenizer/template fingerprint"}]
+ kwargs = {"tokenize": True, "add_generation_prompt": True, "enable_thinking": False}
+ rendered_ids = normalize_chat_template_ids(
+ tokenizer.apply_chat_template(messages, **kwargs))
+ vocabulary = tokenizer.get_vocab()
+ tokenizer_digest = digest({
+ "class": type(tokenizer).__name__,
+ "vocab": sorted((str(key), int(value)) for key, value in vocabulary.items()),
+ })
+ template = digest({"messages": messages, "kwargs": kwargs, "token_ids": rendered_ids})
+ return tokenizer_digest, template
+
+
+class RolloutRuntime:
+ """One gateway, one trace registry and one rollout root per run."""
+
+ def __init__(self, config, tokenizer, *, backend=None):
+ from harborrl.gateway.server import Gateway, make_server
+ from harborrl.gateway.sglang_backend import SGLangBackend
+ from harborrl.gateway.trace import TraceRegistry
+ from harborrl.math_registry import MATH_REGISTRY
+
+ self.config = config
+ self.run_root = Path(config.run_root).resolve()
+ self.catalog = read_json(config.catalog_path)
+ self.profile = read_json(config.profile_path)
+ self.rollout_root = self.run_root / "native-rollouts"
+ self.rollout_root.mkdir(parents=True, exist_ok=True)
+ self.registry = TraceRegistry(self.run_root / "native-traces")
+ self.semaphore = asyncio.Semaphore(config.concurrency_limit)
+ if config.sandbox:
+ from harborrl.rollout.harbor_job.sandbox_config import SandboxAdmission
+ self.sandbox_admission = SandboxAdmission(config.sandbox)
+ tokenizer_digest, template_digest = tokenizer_fingerprint(tokenizer)
+ if (tokenizer_digest != config.expected_tokenizer_digest
+ or template_digest != config.expected_template_digest):
+ raise ValueError("live tokenizer/template digest differs from the native lock")
+ model = MATH_REGISTRY.model_for_preset(config.model_tag)
+ self.backend = backend or SGLangBackend(
+ config.endpoint, tokenizer, model.adapter,
+ tokenizer_digest=tokenizer_digest, template_digest=template_digest,
+ max_context=config.max_context,
+ audited_raw_logprobs=config.audited_raw_logprobs,
+ )
+ gateway = Gateway(self.registry, self.backend, model=self.profile["model"],
+ max_output_tokens=config.max_output_tokens)
+ self.server = make_server(gateway, host=config.gateway_host,
+ port=config.gateway_port)
+ self._start_gateway()
+
+ def _start_gateway(self):
+ self.thread = threading.Thread(
+ target=self.server.serve_forever, name="harborrl-native-gateway", daemon=True
+ )
+ self.thread.start()
+
+ def close(self):
+ self.server.shutdown()
+ if getattr(self.thread, "is_alive", lambda: False)():
+ self.thread.join(5)
+ self.server.server_close()
+
+ def manifest_path(self, identity):
+ """Deterministic shared-storage path of one trajectory manifest."""
+ if isinstance(identity, dict):
+ identity = Identity(**identity)
+ return self.rollout_root / identity.group_id / identity.attempt_id / "trajectory.v2.json"
+
+
+# Historical name kept for existing hooks and evidence readers.
+NativeRolloutRuntime = RolloutRuntime
+
+_RUNTIME = None
+_RUNTIME_LOCK = asyncio.Lock()
+
+
+async def runtime(config, *, tokenizer=None, tokenizer_loader=None):
+ """Process-wide runtime cache for the embedded GPU-side rollout path."""
+ global _RUNTIME
+ if _RUNTIME is None:
+ async with _RUNTIME_LOCK:
+ if _RUNTIME is None:
+ if tokenizer is None:
+ if tokenizer_loader is None:
+ raise ValueError("runtime requires a tokenizer or a loader")
+ tokenizer = tokenizer_loader()
+ _RUNTIME = RolloutRuntime(config, tokenizer)
+ return _RUNTIME
+
+
+def publish_native_version(runtime_, states, expected):
+ """Open the next admission epoch only after the complete pool is healthy."""
+ runtime_.registry.drain(runtime_.config.drain_timeout)
+ runtime_.registry.publish_version(states, str(expected))
+
+
+def current_policy_version(runtime_):
+ """Publish and lock the version currently served by the rollout pool."""
+ states = runtime_.backend.pool_states()
+ from harborrl.platform.policy_pool import publish_pool
+ version = publish_pool(states)
+ runtime_.registry.drain(runtime_.config.drain_timeout)
+ runtime_.registry.publish_version(states, version)
+ return version
+
+
+def close_runtime():
+ global _RUNTIME
+ if _RUNTIME is not None:
+ _RUNTIME.close()
+ _RUNTIME = None
+
+
+def task_entry(sample):
+ metadata = sample.metadata if isinstance(sample.metadata, dict) else {}
+ entry = metadata.get("native_task")
+ if not isinstance(entry, dict) or set(entry) != TASK_ENTRY_KEYS:
+ raise ValueError("native rollout requires a locked task catalog entry")
+ return entry
+
+
+def slot_identity(config, group_index, slot, rollout_id, entry,
+ reward_digest, sampling_digest, policy_version, *, harness_digest=None):
+ return Identity(
+ run_id=config.run_id,
+ batch_id=f"rollout-{rollout_id}",
+ group_id=f"group-{group_index}",
+ slot_id=str(slot),
+ attempt_id=secrets.token_hex(16),
+ trajectory_id=secrets.token_hex(16),
+ task_digest=entry["task_digest"],
+ policy_version=str(policy_version),
+ harness_digest=harness_digest if harness_digest is not None else digest(read_json(config.profile_path)),
+ reward_digest=reward_digest,
+ sampling_digest=sampling_digest,
+ )
+
+
+def _group_slots(config, runtime_, group_index, rollout_id, entry,
+ sampling_digest, policy_version, size):
+ reward_profile = RewardProfile(**entry["reward_profile"])
+ harness_digest = digest(runtime_.profile)
+ identities = [
+ slot_identity(config, group_index, index, rollout_id, entry,
+ reward_profile.digest, sampling_digest, policy_version,
+ harness_digest=harness_digest)
+ for index in range(size)
+ ]
+ return GroupSlots(identities, max_attempts=config.max_attempts), reward_profile
+
+
+async def run_slot(config, runtime_, group_index, slot, rollout_id, entry,
+ sampling_digest, policy_version):
+ """Compatibility entry point for executing an individual group slot."""
+ slots, reward_profile = _group_slots(
+ config, runtime_, group_index, rollout_id, entry,
+ sampling_digest, policy_version, config.group_size)
+ return await _run_slot(config, runtime_, slots, slot, entry, reward_profile)
+
+
+async def _run_slot(config, runtime_, slots, slot, entry, reward_profile):
+ """Execute one slot with the group's shared identity and retry bookkeeping."""
+ workers = config.workers
+ overrides = config.task_worker_overrides
+ for attempt_index in range(slots.max_attempts):
+ identity = slots.start(str(slot))
+ runner_host = overrides.get(entry["id"])
+ if config.runner_transport == 'local':
+ runner_host = None
+ elif runner_host is None:
+ if not workers:
+ raise ValueError("rollout runtime requires at least one worker destination")
+ runner_host = workers[(slot + attempt_index) % len(workers)]
+ root = runtime_.rollout_root / identity.group_id / identity.attempt_id
+ admission = nullcontext()
+ if config.sandbox:
+ from harborrl.rollout.harbor_job.sandbox_config import task_binding
+ admission = runtime_.sandbox_admission.lease(task_binding(config.sandbox, entry['path']), root)
+ try:
+ async with admission, runtime_.semaphore:
+ ir = await run_attempt(
+ identity,
+ task_path=entry["path"],
+ profile=runtime_.profile,
+ gateway_url=config.gateway_url,
+ runner_python=config.runner_python,
+ runner_host=runner_host,
+ root=root,
+ registry=runtime_.registry,
+ reward_profile=reward_profile,
+ max_output_tokens=config.max_output_tokens,
+ **({'sandbox': config.sandbox} if config.sandbox else {}),
+ )
+ except (OSError, ValueError, RuntimeError, asyncio.TimeoutError) as exc:
+ slots.finish(str(slot), error=f"{type(exc).__name__}: {exc}")
+ if config.sandbox and ((root / 'cleanup-required.json').exists() or runtime_.sandbox_admission.blocked):
+ raise RuntimeError('sandbox cleanup unresolved; automatic retry blocked') from exc
+ continue
+ slots.finish(str(slot), ir)
+ return ir
+ raise RuntimeError(f"native slot {slot} exhausted its retry budget")
+
+
+async def run_group(config, runtime_, *, entry, group_index, rollout_id,
+ sampling_digest, policy_version, group_size=None):
+ """Execute one complete group and return per-slot IRs in slot order."""
+ size = config.group_size if group_size is None else int(group_size)
+ slots, reward_profile = _group_slots(
+ config, runtime_, group_index, rollout_id, entry,
+ sampling_digest, policy_version, size)
+ tasks = [asyncio.create_task(
+ _run_slot(config, runtime_, slots, slot, entry, reward_profile))
+ for slot in range(size)
+ ]
+ try:
+ return list(await asyncio.gather(*tasks))
+ except BaseException:
+ for task in tasks:
+ task.cancel()
+ await asyncio.gather(*tasks, return_exceptions=True)
+ raise
diff --git a/harborrl/rollout/service.py b/harborrl/rollout/service.py
new file mode 100644
index 000000000..e223ab05a
--- /dev/null
+++ b/harborrl/rollout/service.py
@@ -0,0 +1,562 @@
+"""RolloutService (P3): the manager-side HTTP control plane for rollout groups.
+
+First-phase contract: HTTP JSON, run-scoped tokens, idempotent group
+submission. Bulky payloads (trajectory manifests, token ids, logprobs, loss
+masks) live on the shared run directory; HTTP responses carry paths plus
+SHA-256 digests, and the SlimeAdapter recomputes every digest before
+converting anything into training samples.
+
+Rules that keep the lifecycle safe:
+- same group id and same content -> the existing state, never a second run;
+- same group id and different content -> rejected;
+- after a timeout the client must query, never resubmit;
+- a new policy version is admitted only after the whole serving pool is
+ healthy on it (drain first, publish second).
+"""
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import hmac
+import json
+import secrets
+import threading
+import time
+from dataclasses import dataclass, field
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from urllib.parse import urlsplit
+
+from harborrl.rollout.exporters.native import export_group
+from harborrl.rollout.runtime import run_group
+from harborrl.trajectories.native import digest, finite, publish, read_json
+
+SERVICE_SCHEMA = "harborrl-rollout-service-v1"
+GROUP_REQUEST_KEYS = {"group_id", "rollout_id", "group_index", "policy_version",
+ "task", "sampling", "slots"}
+SAMPLING_KEYS = {"temperature", "top_p", "top_k", "max_new_tokens", "stop"}
+TASK_ENTRY_KEYS = {"id", "revision", "path", "task_digest", "reward_profile"}
+GROUP_DIR = "rollout-service/groups"
+
+
+class ServiceError(Exception):
+ """Maps onto one HTTP status; carries a machine-readable code."""
+
+ def __init__(self, status, code, message):
+ super().__init__(message)
+ self.status, self.code = status, code
+
+
+def validate_group_request(request):
+ if not isinstance(request, dict) or set(request) != GROUP_REQUEST_KEYS:
+ raise ServiceError(400, "invalid_request",
+ f"group request requires exactly {sorted(GROUP_REQUEST_KEYS)}")
+ for key in ("group_id", "policy_version"):
+ if not isinstance(request[key], str) or not request[key].strip():
+ raise ServiceError(400, "invalid_request", f"{key} must be a nonempty string")
+ for key in ("rollout_id", "group_index"):
+ if type(request[key]) is not int or request[key] < 0:
+ raise ServiceError(400, "invalid_request", f"{key} must be a nonnegative integer")
+ task = request["task"]
+ if not isinstance(task, dict) or set(task) != TASK_ENTRY_KEYS:
+ raise ServiceError(400, "invalid_request",
+ f"task requires exactly {sorted(TASK_ENTRY_KEYS)}")
+ if not isinstance(task["reward_profile"], dict):
+ raise ServiceError(400, "invalid_request", "task.reward_profile must be a mapping")
+ sampling = request["sampling"]
+ if not isinstance(sampling, dict) or set(sampling) != SAMPLING_KEYS:
+ raise ServiceError(400, "invalid_request",
+ f"sampling requires exactly {sorted(SAMPLING_KEYS)}")
+ if not finite(sampling["temperature"]) or not finite(sampling["top_p"]):
+ raise ServiceError(400, "invalid_request", "sampling probabilities must be finite")
+ if type(sampling["top_k"]) is not int:
+ raise ServiceError(400, "invalid_request", "sampling.top_k must be an integer")
+ if type(sampling["max_new_tokens"]) is not int or sampling["max_new_tokens"] < 1:
+ raise ServiceError(400, "invalid_request", "sampling.max_new_tokens must be positive")
+ if (not isinstance(sampling["stop"], list)
+ or any(not isinstance(stop, str) or not stop for stop in sampling["stop"])):
+ raise ServiceError(400, "invalid_request", "sampling.stop must be a string list")
+ slots = request["slots"]
+ if (not isinstance(slots, list) or len(slots) < 2
+ or any(not isinstance(slot, dict) or set(slot) != {"slot_id"}
+ or not isinstance(slot["slot_id"], str) or not slot["slot_id"].strip()
+ for slot in slots)):
+ raise ServiceError(400, "invalid_request",
+ "slots must describe at least two unique slot ids")
+ if len({slot["slot_id"] for slot in slots}) != len(slots):
+ raise ServiceError(400, "invalid_request", "slot ids must be unique")
+ return request
+
+
+def request_digest(request):
+ """Canonical digest of the full request content, group id included."""
+ return digest(request)
+
+
+def manifest_path_from(run_root, identity):
+ """Deterministic trajectory manifest path for one identity."""
+ return Path(run_root) / "native-rollouts" / identity["group_id"] / \
+ identity["attempt_id"] / "trajectory.v2.json"
+
+
+def build_group_result(run_id, request, trajectories, *, runtime, export=None):
+ if len(trajectories) != len(request["slots"]):
+ raise ServiceError(500, "incomplete_group",
+ "executor returned a different slot count")
+ slots = []
+ for descriptor, ir in zip(request["slots"], trajectories):
+ identity = ir["identity"]
+ path = runtime.manifest_path(identity)
+ payload = read_json(path)
+ seal = ir["trace_seal"]
+ slots.append({
+ "slot_id": descriptor["slot_id"],
+ "trajectory_id": identity["trajectory_id"],
+ "attempt_id": identity["attempt_id"],
+ "manifest_path": str(path),
+ "manifest_sha256": digest(payload),
+ "finish_reason": ir["termination"],
+ "reward": ir["evaluation"]["training_reward"],
+ "policy_version": identity["policy_version"],
+ "task_digest": identity["task_digest"],
+ "harness_digest": identity["harness_digest"],
+ "sampling_digest": identity["sampling_digest"],
+ "readiness": ir["readiness"],
+ "integrity_errors": ir["integrity_errors"],
+ "trace_seal": {"status": seal["status"], "errors": seal["errors"],
+ "digest": digest(seal)},
+ "verifier_digest": ir["evaluation"].get("source_result_digest"),
+ })
+ result = {
+ "schema_version": SERVICE_SCHEMA,
+ "run_id": run_id,
+ "group_id": request["group_id"],
+ "rollout_id": request["rollout_id"],
+ "request_digest": request_digest(request),
+ "policy_version": request["policy_version"],
+ "status": "complete",
+ "slots": slots,
+ "completed_at": time.time(),
+ }
+ if export is not None:
+ result["group_export"] = export
+ return result
+
+
+async def default_executor(config, runtime_, request):
+ """The production executor: the framework-free rollout core."""
+ return await run_group(
+ config, runtime_, entry=request["task"], group_index=request["group_index"],
+ rollout_id=request["rollout_id"], sampling_digest=digest(request["sampling"]),
+ policy_version=request["policy_version"], group_size=len(request["slots"]))
+
+
+@dataclass
+class GroupState:
+ request: dict
+ request_sha: str
+ status: str = "queued"
+ result: dict = None
+ error: str = None
+ created_at: float = field(default_factory=time.time)
+ future: object = None
+
+ def summary(self, *, include_result=True):
+ payload = {"group_id": self.request["group_id"], "status": self.status,
+ "request_digest": self.request_sha,
+ "created_at": self.created_at}
+ if self.error:
+ payload["error"] = self.error
+ if include_result and self.result is not None:
+ payload["result"] = self.result
+ return payload
+
+
+@dataclass
+class RunState:
+ run_id: str
+ manifest: dict
+ manifest_sha: str
+ token_digest: str
+ config: object = None
+ runtime: object = None
+ drained: bool = False
+ serving: str = None
+ groups: dict = field(default_factory=dict)
+
+
+class RolloutService:
+ """Run registry plus a single-threaded asyncio executor for group work."""
+
+ def __init__(self, *, run_root, executor=None, runtime_factory=None,
+ token_file=None, group_wait_timeout=3600.0, poll_interval=0.05,
+ allow_synthetic=False):
+ self.run_root = Path(run_root).resolve()
+ self.executor = executor or default_executor
+ self.runtime_factory = runtime_factory
+ self.allow_synthetic = allow_synthetic
+ self.token = None
+ if token_file is not None:
+ token = Path(token_file).read_text().strip()
+ if not token:
+ raise ServiceError(500, "invalid_token_file", "run token file is empty")
+ self.token = token
+ self.group_wait_timeout = group_wait_timeout
+ self.poll_interval = poll_interval
+ self.runs = {}
+ self.lock = threading.RLock()
+ self.loop = asyncio.new_event_loop()
+ self.thread = threading.Thread(target=self._serve_loop,
+ name="harborrl-rollout-service", daemon=True)
+ self.thread.start()
+
+ def _serve_loop(self):
+ asyncio.set_event_loop(self.loop)
+ self.loop.run_forever()
+
+ def close(self):
+ with self.lock:
+ states = [state for state in self.runs.values() if state.runtime is not None]
+ for state in states:
+ try:
+ state.runtime.close()
+ except AttributeError:
+ pass
+ self.loop.call_soon_threadsafe(self.loop.stop)
+ self.thread.join(5)
+
+ # -- run lifecycle ----------------------------------------------------
+
+ def _token_digest(self, token):
+ return hashlib.sha256(token.encode()).hexdigest()
+
+ def register_run(self, run_id, manifest, *, token=None):
+ if not isinstance(run_id, str) or not run_id.strip():
+ raise ServiceError(400, "invalid_request", "run id required")
+ if not isinstance(manifest, dict) or not manifest:
+ raise ServiceError(400, "invalid_request", "manifest must be a nonempty mapping")
+ if manifest.get("run_id", run_id) != run_id:
+ raise ServiceError(400, "invalid_request", "manifest run id must match the path")
+ manifest_sha = digest(manifest)
+ with self.lock:
+ existing = self.runs.get(run_id)
+ if existing is not None:
+ if existing.manifest_sha != manifest_sha:
+ raise ServiceError(409, "conflict",
+ "run id already registered with a different manifest")
+ return {"run_id": run_id, "state": "registered", "existed": True}
+ if self.token is not None and token != self.token:
+ raise ServiceError(401, "unauthorized", "invalid manager run token")
+ generated = token is None
+ if generated:
+ token = secrets.token_urlsafe(32)
+ self.runs[run_id] = RunState(run_id=run_id, manifest=manifest,
+ manifest_sha=manifest_sha,
+ token_digest=self._token_digest(token))
+ return {"run_id": run_id, "state": "registered", "existed": False,
+ **({"run_token": token} if generated else {})}
+
+ def authenticate(self, run_id, token):
+ with self.lock:
+ state = self.runs.get(run_id)
+ if state is None:
+ raise ServiceError(404, "not_found", f"unknown run: {run_id}")
+ if not isinstance(token, str) or not token or not hmac.compare_digest(
+ state.token_digest, self._token_digest(token)):
+ raise ServiceError(401, "unauthorized", "invalid run token")
+ return state
+
+ def _run(self, run_id):
+ state = self.runs.get(run_id)
+ if state is None:
+ raise ServiceError(404, "not_found", f"unknown run: {run_id}")
+ return state
+
+ def attach_runtime(self, run_id, config, runtime):
+ """Bind the rollout runtime that executes this run's groups."""
+ with self.lock:
+ state = self._run(run_id)
+ state.config, state.runtime = config, runtime
+ return {"run_id": run_id, "state": "attached"}
+
+ def announce_serving(self, run_id, router_url):
+ parts = urlsplit(router_url or "")
+ if (parts.scheme not in ("http", "https") or not parts.hostname
+ or parts.username or parts.password or parts.query
+ or parts.fragment or parts.path not in ("", "/")):
+ raise ServiceError(400, "invalid_request", "router_url must be a plain origin")
+ with self.lock:
+ state = self._run(run_id)
+ state.serving = f"{parts.scheme}://{parts.netloc}".rstrip("/")
+ if state.runtime is None and self.runtime_factory is not None:
+ runtime = self.runtime_factory(run_id, state.serving)
+ state.runtime = runtime
+ if state.config is None:
+ # The factory-built runtime carries its own config; the
+ # executor needs both for group admission.
+ state.config = getattr(runtime, "config", None)
+ return {"run_id": run_id, "router_url": state.serving,
+ "runtime": "attached" if state.runtime is not None else "pending"}
+
+ # -- policy lifecycle (§7) ---------------------------------------------
+
+ def activate_policy(self, run_id, states, expected):
+ from harborrl.platform.policy_pool import validate_pool
+
+ with self.lock:
+ state = self._run(run_id)
+ if state.runtime is None:
+ raise ServiceError(409, "admission_closed", "run has no serving runtime yet")
+ try:
+ version = validate_pool(states, str(expected))
+ backend = getattr(state.runtime, 'backend', None)
+ if backend is not None:
+ live_states = backend.pool_states()
+ validate_pool(live_states, version)
+ if {item['endpoint'] for item in live_states} != {item['endpoint'] for item in states}:
+ raise ValueError('manager and GPU disagree on serving engine membership')
+ states = live_states
+ except (RuntimeError, TypeError, ValueError) as exc:
+ raise ServiceError(409, "pool_invalid", str(exc)) from exc
+ registry = state.runtime.registry
+ registry.drain(float(getattr(state.config, "drain_timeout", 30)))
+ registry.publish_version(states, version)
+ # Mirror policy_pool.publish_pool into the shared run directory so the
+ # GPU side can lock the same version without manager-local state; the
+ # record is replaceable because activation legitimately advances.
+ payload = {"weight_version": version, "engines": states, "timestamp": time.time()}
+ temp = self.run_root / "policy-pool.tmp"
+ temp.write_text(json.dumps(payload) + "\n")
+ temp.replace(self.run_root / "policy-pool.json")
+ return {"run_id": run_id, "policy_version": version, "admission": "open"}
+
+ # -- group lifecycle ----------------------------------------------------
+
+ def _group_dir(self, run_id, group_id):
+ return self.run_root / GROUP_DIR / run_id / group_id
+
+ def submit_group(self, run_id, request, *, wait=True):
+ request = validate_group_request(request)
+ sha = request_digest(request)
+ with self.lock:
+ state = self._run(run_id)
+ existing = state.groups.get(request["group_id"])
+ if existing is not None:
+ if existing.request_sha != sha:
+ raise ServiceError(409, "conflict",
+ "group id already submitted with different content")
+ group = existing
+ else:
+ if state.drained:
+ raise ServiceError(409, "admission_closed", "run is draining")
+ if state.runtime is None or state.config is None:
+ raise ServiceError(409, "admission_closed",
+ "run has no rollout runtime attached")
+ registry = getattr(state.runtime, "registry", None)
+ version = getattr(registry, "version", None) if registry is not None else None
+ accepting = getattr(registry, "accepting", True) if registry is not None else False
+ if not accepting or version != request["policy_version"]:
+ raise ServiceError(
+ 409, "stale_policy",
+ f"policy {request['policy_version']!r} is not the admitted version "
+ f"({version!r}, accepting={accepting}); activate first")
+ group = GroupState(request=request, request_sha=sha)
+ state.groups[request["group_id"]] = group
+ future = asyncio.run_coroutine_threadsafe(
+ self._execute(state, group), self.loop)
+ group.future = future
+ if wait:
+ return self._wait_group(run_id, group)
+ return group.summary(include_result=False)
+
+ def _wait_group(self, run_id, group):
+ deadline = time.monotonic() + self.group_wait_timeout
+ while group.status in ("queued", "running"):
+ if time.monotonic() >= deadline:
+ return {**group.summary(include_result=False),
+ "hint": "query GET /groups/{group_id}; do not resubmit"}
+ time.sleep(self.poll_interval)
+ return group.summary()
+
+ async def _execute(self, state, group):
+ # Immutable state records: each transition gets its own file, and
+ # publish() refuses to rewrite history.
+ request = group.request
+ directory = self._group_dir(state.run_id, request["group_id"])
+ publish(directory / "request.json", request)
+ group.status = "running"
+ publish(directory / "running.json", group.summary(include_result=False))
+ try:
+ trajectories = await self.executor(state.config, state.runtime, request)
+ export = export_group(
+ trajectories, len(trajectories),
+ allow_synthetic=self.allow_synthetic)
+ export_path = directory / "group-export.json"
+ publish(export_path, export)
+ group.result = build_group_result(
+ state.run_id, request, trajectories, runtime=state.runtime,
+ export={"path": str(export_path), "sha256": digest(export)})
+ group.status = "complete"
+ publish(directory / "result.json", group.result)
+ except asyncio.CancelledError:
+ group.status = "cancelled"
+ publish(directory / "cancelled.json", group.summary(include_result=False))
+ raise
+ except Exception as exc: # noqa: BLE001 — any executor failure is
+ # recorded as a failed group instead of killing the service
+ group.status = "failed"
+ group.error = f"{type(exc).__name__}: {exc}"
+ publish(directory / "failed.json", group.summary(include_result=False))
+
+ def group_status(self, run_id, group_id):
+ with self.lock:
+ state = self._run(run_id)
+ group = state.groups.get(group_id)
+ if group is None:
+ raise ServiceError(404, "not_found", f"unknown group: {group_id}")
+ return group.summary()
+
+ def cancel_group(self, run_id, group_id):
+ with self.lock:
+ state = self._run(run_id)
+ group = state.groups.get(group_id)
+ if group is None:
+ raise ServiceError(404, "not_found", f"unknown group: {group_id}")
+ self._cancel_groups([group])
+ return group.summary()
+
+ def drain_run(self, run_id):
+ with self.lock:
+ state = self._run(run_id)
+ state.drained = True
+ runtime = state.runtime
+ if runtime is not None:
+ registry = getattr(runtime, "registry", None)
+ if registry is not None:
+ timeout = float(getattr(state.config, "drain_timeout", 30))
+ try:
+ registry.drain(timeout)
+ except TimeoutError as exc:
+ raise ServiceError(409, "drain_timeout",
+ f"generation still in flight: {exc}") from exc
+ return {"run_id": run_id, "state": "drained"}
+
+ def cancel_run(self, run_id):
+ with self.lock:
+ state = self._run(run_id)
+ groups = list(state.groups.values())
+ self._cancel_groups(groups)
+ return {"run_id": run_id, "state": "cancelled",
+ "groups": {group.request["group_id"]: group.status for group in groups}}
+
+ def _cancel_groups(self, groups):
+ for group in groups:
+ if group.status in ("queued", "running") and group.future is not None:
+ group.future.cancel()
+ deadline = time.monotonic() + 30
+ while any(group.status in ("queued", "running") for group in groups) \
+ and time.monotonic() < deadline:
+ time.sleep(self.poll_interval)
+
+
+def make_control_server(service, host="127.0.0.1", port=0, *, max_body_bytes=8 * 1024 * 1024):
+ """Bounded stdlib HTTP transport for the control plane."""
+
+ class Handler(BaseHTTPRequestHandler):
+ def setup(self):
+ super().setup()
+ self.connection.settimeout(120)
+
+ def log_message(self, *args):
+ pass
+
+ def reply(self, status, payload):
+ body = json.dumps(payload).encode()
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def token(self):
+ token = self.headers.get("x-api-key", "")
+ auth = self.headers.get("Authorization", "")
+ if auth:
+ if not auth.startswith("Bearer ") or token:
+ raise ServiceError(400, "invalid_request", "ambiguous authentication")
+ token = auth[7:]
+ return token
+
+ def read_json(self):
+ if self.headers.get("Transfer-Encoding"):
+ raise ServiceError(400, "invalid_request", "chunked bodies are not supported")
+ size = int(self.headers.get("Content-Length", "0"))
+ if size <= 0 or size > max_body_bytes:
+ raise ServiceError(400, "invalid_request", "invalid request body size")
+ return json.loads(self.rfile.read(size))
+
+ def dispatch(self, method):
+ parts = [part for part in urlsplit(self.path).path.split("/") if part]
+ try:
+ payload = self.route(method, parts)
+ except ServiceError as exc:
+ self.reply(exc.status, {"error": {"type": exc.code, "message": str(exc)}})
+ except (ValueError, TypeError, KeyError, OSError, json.JSONDecodeError) as exc:
+ self.reply(400, {"error": {"type": "invalid_request",
+ "message": f"{type(exc).__name__}: {exc}"}})
+ else:
+ self.reply(200, payload)
+
+ def route(self, method, parts):
+ if method == "GET" and parts == ["healthz"]:
+ return {"ok": True, "service": SERVICE_SCHEMA}
+ if method == "GET" and parts == ["readyz"]:
+ with service.lock:
+ attached = sum(1 for state in service.runs.values()
+ if state.runtime is not None)
+ return {"ok": True, "runs": len(service.runs), "attached_runs": attached}
+ if len(parts) < 2 or parts[0] != "runs":
+ raise ServiceError(404, "not_found", "unsupported endpoint")
+ run_id = parts[1]
+ if len(parts) == 3 and method == "POST" and parts[2] == "register":
+ body = self.read_json()
+ if not isinstance(body, dict) or set(body) != {"manifest"}:
+ raise ServiceError(400, "invalid_request", "body must be {manifest}")
+ return service.register_run(run_id, body["manifest"], token=self.token())
+ service.authenticate(run_id, self.token())
+ if len(parts) == 3 and method == "POST" and parts[2] == "groups":
+ return service.submit_group(run_id, self.read_json())
+ if (len(parts) == 4 and parts[2] == "groups"
+ and method == "GET"):
+ return service.group_status(run_id, parts[3])
+ if (len(parts) == 5 and parts[2] == "groups" and parts[4] == "cancel"
+ and method == "POST"):
+ return service.cancel_group(run_id, parts[3])
+ if len(parts) == 4 and method == "POST" and parts[2] == "policy" \
+ and parts[3] == "activate":
+ body = self.read_json()
+ if not isinstance(body, dict) or set(body) != {"states", "expected"}:
+ raise ServiceError(400, "invalid_request",
+ "body must be {states, expected}")
+ return service.activate_policy(run_id, body["states"], body["expected"])
+ if len(parts) == 4 and method == "POST" and parts[2] == "serving" \
+ and parts[3] == "announce":
+ body = self.read_json()
+ if not isinstance(body, dict) or set(body) != {"router_url"}:
+ raise ServiceError(400, "invalid_request", "body must be {router_url}")
+ return service.announce_serving(run_id, body["router_url"])
+ if len(parts) == 3 and method == "POST" and parts[2] == "drain":
+ return service.drain_run(run_id)
+ if len(parts) == 3 and method == "POST" and parts[2] == "cancel":
+ return service.cancel_run(run_id)
+ raise ServiceError(404, "not_found", "unsupported endpoint")
+
+ def do_GET(self):
+ self.dispatch("GET")
+
+ def do_POST(self):
+ self.dispatch("POST")
+
+ server = ThreadingHTTPServer((host, port), Handler)
+ server.daemon_threads = True
+ return server
diff --git a/harborrl/tasks/catalog.py b/harborrl/tasks/catalog.py
new file mode 100644
index 000000000..d887e7ec6
--- /dev/null
+++ b/harborrl/tasks/catalog.py
@@ -0,0 +1,8 @@
+"""Compatibility entry point for the selected Harbor task provider."""
+from __future__ import annotations
+
+from harborrl.math_registry import select_math
+
+
+def catalog(config):
+ return select_math(config).task.adapter.catalog(config)
diff --git a/harborrl/tasks/cli.py b/harborrl/tasks/cli.py
index 8cbb3c5d3..f9d337cd3 100644
--- a/harborrl/tasks/cli.py
+++ b/harborrl/tasks/cli.py
@@ -7,7 +7,7 @@
from pathlib import Path
from uuid import uuid4
-from harborrl.data.harbor.native_inspector import inspect_native
+from harborrl.math_registry import NATIVE_TASK_PROVIDER
def main(argv=None):
@@ -23,12 +23,12 @@ def main(argv=None):
if args.command == "inspect":
if not args.tasks_dir.is_dir():
parser.error("tasks-dir must exist")
- tasks = sorted(path.parent for path in args.tasks_dir.rglob("task.toml"))
+ tasks = NATIVE_TASK_PROVIDER.adapter.discover(args.tasks_dir)
if not tasks:
parser.error("no task.toml found")
rows = []
for task in tasks:
- result = inspect_native(task)
+ result = NATIVE_TASK_PROVIDER.adapter.inspect(task)
rows.append({
**result,
"dataset": args.dataset,
diff --git a/harborrl/tasks/content.py b/harborrl/tasks/content.py
new file mode 100644
index 000000000..e108f167e
--- /dev/null
+++ b/harborrl/tasks/content.py
@@ -0,0 +1,26 @@
+"""Native task content hashing and TOML reader shared with compatibility tools."""
+import hashlib
+from pathlib import Path
+
+try:
+ import tomllib
+except ModuleNotFoundError: # Python 3.10
+ import tomli as tomllib
+
+
+__all__ = ["tomllib", "tree_digest"]
+
+
+def tree_digest(root: Path, *, exclude: tuple[str, ...] = ()) -> str:
+ h = hashlib.sha256()
+ for path in sorted(Path(root).rglob("*")):
+ if str(path.relative_to(root)) in exclude:
+ continue
+ if path.is_symlink():
+ raise ValueError("symlinks are not supported")
+ if path.is_file():
+ data = path.read_bytes()
+ h.update(str(path.relative_to(root)).encode() + b"\0")
+ h.update(str(path.stat().st_mode & 0o777).encode() + b"\0")
+ h.update(str(len(data)).encode() + b"\0" + data)
+ return h.hexdigest()
diff --git a/harborrl/tasks/jsonl_provider.py b/harborrl/tasks/jsonl_provider.py
new file mode 100644
index 000000000..4fdeae0fd
--- /dev/null
+++ b/harborrl/tasks/jsonl_provider.py
@@ -0,0 +1,92 @@
+"""JSONL Harbor task catalog provider with the same native task lock contract."""
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+
+from harborrl.data.harbor.native_inspector import inspect_native
+from harborrl.trajectories.native import RewardProfile
+
+
+class JsonlTaskProvider:
+ provider_id = "harbor_jsonl"
+
+ def discover(self, tasks_dir):
+ return sorted(path.parent for path in Path(tasks_dir).rglob("task.toml"))
+
+ def inspect(self, path, *, expected_digest=None):
+ return inspect_native(path, expected_digest=expected_digest)
+
+ def catalog(self, config):
+ path = Path(config["tasks"]["catalog"])
+ entries, seen = [], set()
+ with path.open(encoding="utf-8") as source:
+ for line_number, line in enumerate(source, start=1):
+ if not line.strip():
+ raise ValueError(f"blank JSONL catalog line: {line_number}")
+ try:
+ entry = json.loads(line)
+ except json.JSONDecodeError as exc:
+ raise ValueError(
+ f"invalid JSONL catalog line {line_number}: {exc.msg}"
+ ) from exc
+ if (
+ not isinstance(entry, dict)
+ or set(entry)
+ != {"id", "revision", "path", "task_digest", "reward_profile"}
+ or not isinstance(entry["id"], str)
+ or not entry["id"].strip()
+ or not isinstance(entry["revision"], str)
+ or not entry["revision"].strip()
+ or entry["id"] in seen
+ ):
+ raise ValueError(
+ f"invalid or duplicate JSONL catalog identity: {line_number}"
+ )
+ if (
+ not isinstance(entry["path"], str)
+ or not entry["path"].strip()
+ or not isinstance(entry["task_digest"], str)
+ or not re.fullmatch(r"[0-9a-f]{64}", entry["task_digest"])
+ or not isinstance(entry["reward_profile"], dict)
+ ):
+ raise ValueError(f'invalid native task lock: {entry["id"]}')
+ seen.add(entry["id"])
+ entry["path"] = str((path.parent / entry["path"]).resolve())
+ report = self.inspect(
+ entry["path"], expected_digest=entry["task_digest"]
+ )
+ if report["status"] != "NEEDS_PROBE":
+ raise ValueError(
+ f'task preflight failed: {entry["id"]}: {report["reasons"]}'
+ )
+ RewardProfile(**entry["reward_profile"])
+ entries.append(entry)
+ if not entries:
+ raise ValueError("catalog must contain at least one JSONL task lock")
+ selected = set(getattr(config, "task_ids", ()))
+ if selected:
+ missing = selected - {entry["id"] for entry in entries}
+ if missing:
+ raise ValueError(
+ f"task selector not found in catalog: {min(missing)}"
+ )
+ entries = [entry for entry in entries if entry["id"] in selected]
+ return entries
+
+ def prompt_rows(self, entries):
+ rows = []
+ for entry in entries:
+ instruction = (Path(entry["path"]) / "instruction.md").read_text()
+ rows.append(
+ {
+ "task": instruction,
+ "metadata": {
+ "harborrl_native": True,
+ "task_provider": self.provider_id,
+ "native_task": entry,
+ },
+ }
+ )
+ return rows
diff --git a/harborrl/tasks/provider.py b/harborrl/tasks/provider.py
new file mode 100644
index 000000000..239fd58ef
--- /dev/null
+++ b/harborrl/tasks/provider.py
@@ -0,0 +1,72 @@
+"""Native Harbor task discovery, locking, and prompt materialization."""
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+from harborrl.data.harbor.native_inspector import inspect_native
+from harborrl.trajectories.native import RewardProfile, read_json
+
+
+class NativeTaskProvider:
+ provider_id = "native_harbor"
+
+ def discover(self, tasks_dir):
+ return sorted(path.parent for path in Path(tasks_dir).rglob("task.toml"))
+
+ def inspect(self, path, *, expected_digest=None):
+ return inspect_native(path, expected_digest=expected_digest)
+
+ def catalog(self, config):
+ path = Path(config["tasks"]["catalog"])
+ entries = read_json(path)
+ if not isinstance(entries, list) or not entries:
+ raise ValueError("catalog must be a nonempty JSON list")
+ seen = set()
+ for entry in entries:
+ if (
+ not isinstance(entry, dict)
+ or set(entry)
+ != {"id", "revision", "path", "task_digest", "reward_profile"}
+ or not isinstance(entry["id"], str)
+ or not entry["id"].strip()
+ or not isinstance(entry["revision"], str)
+ or not entry["revision"].strip()
+ or entry["id"] in seen
+ ):
+ raise ValueError("invalid or duplicate catalog identity")
+ if (
+ not isinstance(entry["path"], str)
+ or not entry["path"].strip()
+ or not isinstance(entry["task_digest"], str)
+ or not re.fullmatch(r"[0-9a-f]{64}", entry["task_digest"])
+ or not isinstance(entry["reward_profile"], dict)
+ ):
+ raise ValueError(f'invalid native task lock: {entry["id"]}')
+ seen.add(entry["id"])
+ entry["path"] = str((path.parent / entry["path"]).resolve())
+ report = self.inspect(entry["path"], expected_digest=entry["task_digest"])
+ if report["status"] != "NEEDS_PROBE":
+ raise ValueError(
+ f'task preflight failed: {entry["id"]}: {report["reasons"]}'
+ )
+ RewardProfile(**entry["reward_profile"])
+ selected = set(getattr(config, "task_ids", ()))
+ if selected:
+ missing = selected - {entry["id"] for entry in entries}
+ if missing:
+ raise ValueError(f"task selector not found in catalog: {min(missing)}")
+ entries = [entry for entry in entries if entry["id"] in selected]
+ return entries
+
+ def prompt_rows(self, entries):
+ rows = []
+ for entry in entries:
+ instruction = (Path(entry["path"]) / "instruction.md").read_text()
+ rows.append(
+ {
+ "task": instruction,
+ "metadata": {"harborrl_native": True, "native_task": entry},
+ }
+ )
+ return rows
diff --git a/harborrl/webui/__init__.py b/harborrl/webui/__init__.py
new file mode 100644
index 000000000..765ec5b8e
--- /dev/null
+++ b/harborrl/webui/__init__.py
@@ -0,0 +1 @@
+"""Local configuration and training console; runtime imports stay lazy."""
diff --git a/harborrl/webui/cli.py b/harborrl/webui/cli.py
new file mode 100644
index 000000000..1193ce5d8
--- /dev/null
+++ b/harborrl/webui/cli.py
@@ -0,0 +1,67 @@
+"""Start a single-user loopback console, optionally opening the browser."""
+import argparse
+import contextlib
+import os
+import signal
+import webbrowser
+from pathlib import Path
+
+
+@contextlib.contextmanager
+def workspace_lock(workspace):
+ workspace.mkdir(parents=True, exist_ok=True)
+ with (workspace / ".console.lock").open("a+b") as stream:
+ try:
+ if os.name == "posix":
+ import fcntl
+ fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
+ else:
+ import msvcrt
+ stream.seek(0)
+ if stream.read(1) == b"":
+ stream.write(b"0")
+ stream.flush()
+ stream.seek(0)
+ msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
+ except OSError as exc:
+ raise ValueError("Another console owns this workspace") from exc
+ yield
+
+
+def main(argv):
+ parser = argparse.ArgumentParser(prog="harborrl web", description=__doc__)
+ parser.add_argument("--workspace", default="./harborrl-web", help="Configurations and job logs directory")
+ parser.add_argument("--port", type=int, default=0, help="Loopback port; 0 selects a free port")
+ parser.add_argument("--no-browser", action="store_true", help="Print the URL without opening a browser")
+ args = parser.parse_args(argv)
+ if not 0 <= args.port <= 65535:
+ parser.error("port must be in 0..65535")
+ from .server import Console, make_server
+
+ workspace = Path(args.workspace).expanduser().resolve()
+ try:
+ with workspace_lock(workspace):
+ console = Console(workspace)
+ server = make_server(console, args.port)
+ url = f"http://127.0.0.1:{server.server_port}/#token={console.token}"
+ print(f"HarborRL console: {url}\nWorkspace: {workspace}\nKeep this terminal open. Ctrl+C stops the console and requests active job cleanup.", flush=True)
+ if not args.no_browser:
+ try:
+ webbrowser.open(url)
+ except webbrowser.Error:
+ print("Browser unavailable; open the printed URL.", flush=True)
+ previous = signal.getsignal(signal.SIGTERM)
+ def terminate(_signum, _frame):
+ raise KeyboardInterrupt
+ signal.signal(signal.SIGTERM, terminate)
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+ finally:
+ server.server_close()
+ console.jobs.close()
+ signal.signal(signal.SIGTERM, previous)
+ except (OSError, ValueError) as exc:
+ parser.error(str(exc))
+ return 0
diff --git a/harborrl/webui/jobs.py b/harborrl/webui/jobs.py
new file mode 100644
index 000000000..28a36df2b
--- /dev/null
+++ b/harborrl/webui/jobs.py
@@ -0,0 +1,173 @@
+"""One foreground CLI job at a time, with immutable inputs and durable logs."""
+import json
+import os
+import signal
+import subprocess
+import threading
+from datetime import datetime, timezone
+from pathlib import Path
+from uuid import uuid4
+
+import yaml
+
+from harborrl.options.wizard import build_config_bundle, publish_bundle
+
+
+def now():
+ return datetime.now(timezone.utc).isoformat()
+
+
+class Jobs:
+ def __init__(self, root):
+ self.root = Path(root)
+ self.root.mkdir(parents=True, exist_ok=True)
+ self.lock = threading.RLock()
+ self.child = None
+ self.active = None
+ self.closed = False
+ self.records = {}
+ for path in self.root.glob("*/job.json"):
+ record = json.loads(path.read_text(encoding="utf-8"))
+ if record["state"] in {"running", "stopping"}:
+ record.update(state="unknown", message="Console restarted; verify target cleanup before launching again.")
+ self.records[record["id"]] = record
+
+ def _save(self, record):
+ path = self.root / record["id"] / "job.json"
+ temporary = path.with_suffix(".tmp")
+ temporary.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
+ temporary.replace(path)
+
+ def _refresh(self):
+ if self.child is not None and self.child.poll() is not None:
+ record = self.records[self.active]
+ code = self.child.returncode
+ record.update(state="stopped" if record["state"] == "stopping" else "succeeded" if code == 0 else "failed",
+ exit_code=code, finished_at=now())
+ self._save(record)
+ self.child = self.active = None
+
+ def _watch(self, child):
+ child.wait()
+ with self.lock:
+ if self.child is child:
+ self._refresh()
+
+ def list(self):
+ with self.lock:
+ self._refresh()
+ return [dict(record) for record in sorted(self.records.values(), key=lambda r: r["created_at"], reverse=True)]
+
+ def start(self, configuration, bundle, mode):
+ if mode not in {"dry-run", "doctor", "train"}:
+ raise ValueError("Unknown run mode")
+ with self.lock:
+ self._refresh()
+ if self.closed or self.child is not None:
+ raise ValueError("A job is already active or the console is closing")
+ if any(record["state"] == "unknown" for record in self.records.values()):
+ raise ValueError("An interrupted job has unknown cleanup status. Reconcile it before launching again.")
+ identifier = uuid4().hex
+ root = self.root / identifier
+ root.mkdir(mode=0o700)
+ training = yaml.safe_load(bundle["files"]["config.yaml"])
+ deployment = yaml.safe_load(bundle["files"]["deployment.yaml"])
+ manifest = json.loads(bundle["files"]["setup-manifest.json"])
+ output = str(Path(training["output"]["root"]) / "web" / identifier)
+ training["output"]["root"] = deployment["storage"]["run_root"] = output
+ snapshot = build_config_bundle({"answers_version": 1, "preset": manifest["preset"],
+ "training": training, "deployment": deployment}, output_directory=root / "inputs")
+ publish_bundle(snapshot, root / "inputs")
+ manager = deployment["manager"]
+ command = [manager["controller_python"], "-u", "-m", "harborrl.cli", "doctor" if mode == "doctor" else "train",
+ str(root / "inputs/config.yaml"), "--deployment", str(root / "inputs/deployment.yaml")]
+ if mode == "dry-run":
+ command.append("--dry-run")
+ record = {"id": identifier, "configuration": configuration, "mode": mode, "state": "running",
+ "created_at": now(), "finished_at": None, "exit_code": None, "command": command,
+ "log_path": str(root / "console.log"), "snapshot": str(root / "inputs"), "output_root": output}
+ self._save(record)
+ env = {**os.environ, "PYTHONUNBUFFERED": "1"}
+ env["PYTHONPATH"] = os.pathsep.join(filter(None, [manager["source_dir"], env.get("PYTHONPATH")]))
+ try:
+ with (root / "console.log").open("wb") as log:
+ self.child = subprocess.Popen(command, cwd=manager["source_dir"], env=env, stdin=subprocess.DEVNULL,
+ stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
+ except OSError as exc:
+ record.update(state="failed", message=str(exc), finished_at=now())
+ else:
+ self.active = identifier
+ record["pid"] = self.child.pid
+ self.records[identifier] = record
+ self._save(record)
+ if self.child is not None:
+ threading.Thread(target=self._watch, args=(self.child,), name="harborrl-web-job", daemon=True).start()
+ return dict(record)
+
+ def stop(self, identifier):
+ with self.lock:
+ self._refresh()
+ if identifier != self.active or self.child is None:
+ raise ValueError("This job is not active")
+ record = self.records[identifier]
+ if record["state"] != "stopping":
+ # The existing CLI forwards signals to its local/SSH training target.
+ try:
+ self.child.send_signal(signal.SIGINT) if os.name == "posix" else self.child.terminate()
+ except ProcessLookupError:
+ self._refresh()
+ return dict(record)
+ record["state"] = "stopping"
+ self._save(record)
+ return dict(record)
+
+ def reconcile(self, identifier):
+ with self.lock:
+ record = self.records[identifier]
+ if record["state"] != "unknown":
+ raise ValueError("Only an interrupted job can be reconciled")
+ record.update(state="interrupted", finished_at=now(), message="Target cleanup confirmed by operator.")
+ self._save(record)
+ return dict(record)
+
+ def log(self, identifier):
+ with self.lock:
+ self._refresh()
+ record = self.records[identifier]
+ path = self.root / identifier / "console.log"
+ with path.open("rb") as stream:
+ size = stream.seek(0, os.SEEK_END)
+ stream.seek(max(0, size - 65536))
+ tail = stream.read(65536).decode("utf-8", errors="replace")
+ result = {"job": dict(record), "text": tail, "bytes": size, "truncated": size > 65536,
+ "training_text": "", "training_log_path": None, "training_truncated": False}
+ output = Path(record["output_root"])
+ candidates = sorted(output.glob("training/*/train-stdout.log"))
+ if candidates:
+ training_log = candidates[-1]
+ if training_log.resolve().is_relative_to(output.resolve()):
+ with training_log.open("rb") as stream:
+ length = stream.seek(0, os.SEEK_END)
+ stream.seek(max(0, length - 65536))
+ result.update(training_text=stream.read(65536).decode("utf-8", errors="replace"),
+ training_log_path=str(training_log), training_truncated=length > 65536)
+ return result
+
+ def close(self):
+ with self.lock:
+ self.closed = True
+ self._refresh()
+ child = self.child
+ if child:
+ self.stop(self.active)
+ if child:
+ try:
+ child.wait(timeout=30)
+ except subprocess.TimeoutExpired:
+ with self.lock:
+ record = self.records[self.active]
+ record.update(state="unknown", message="Graceful shutdown timed out; target cleanup must be checked.")
+ self._save(record)
+ else:
+ with self.lock:
+ self._refresh()
diff --git a/harborrl/webui/server.py b/harborrl/webui/server.py
new file mode 100644
index 000000000..442699175
--- /dev/null
+++ b/harborrl/webui/server.py
@@ -0,0 +1,189 @@
+"""Loopback-only HTTP boundary for the existing configuration services."""
+import hmac
+import json
+import os
+import re
+import secrets
+import threading
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from importlib.resources import files
+from pathlib import Path
+from urllib.parse import urlsplit
+
+import yaml
+
+from harborrl.options.catalog import build_catalog
+from harborrl.options.presets import PRESETS, defaults
+from harborrl.options.validation import read_mapping, validate_mappings
+from harborrl.options.wizard import build_config_bundle, publish_bundle
+
+from .jobs import Jobs
+
+ASSETS = {"/": ("index.html", "text/html; charset=utf-8"),
+ "/app.js": ("app.js", "text/javascript; charset=utf-8"),
+ "/style.css": ("style.css", "text/css; charset=utf-8"),
+ "/lucide.min.js": ("lucide.min.js", "text/javascript; charset=utf-8"),
+ "/brand.jpg": ("brand.jpg", "image/jpeg")}
+NAME = re.compile(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}")
+
+
+class Console:
+ def __init__(self, workspace):
+ self.workspace = Path(workspace).expanduser().resolve()
+ self.configurations = self.workspace / "configurations"
+ self.configurations.mkdir(parents=True, exist_ok=True)
+ self.jobs = Jobs(self.workspace / "jobs")
+ self.token = secrets.token_urlsafe(32)
+ self.lock = threading.RLock()
+
+ def directory(self, name):
+ if not isinstance(name, str) or not NAME.fullmatch(name):
+ raise ValueError("Name must be 1-64 letters, digits, underscores or hyphens")
+ path = self.configurations / name
+ if path.is_symlink() or path.resolve().parent != self.configurations:
+ raise ValueError("Configuration must stay inside the workspace")
+ return path
+
+ def bootstrap(self):
+ return {"workspace": str(self.workspace), "catalog": build_catalog(),
+ "live_supported": os.name == "posix",
+ "defaults": {item["id"]: dict(zip(("training", "deployment"),
+ defaults(item["id"], self.configurations / "experiment"))) for item in PRESETS},
+ "configurations": self.list_configurations(), "jobs": self.jobs.list()}
+
+ def list_configurations(self):
+ result = []
+ for path in sorted(self.configurations.iterdir()):
+ if path.is_dir() and not path.is_symlink() and NAME.fullmatch(path.name) and (path / "setup-manifest.json").is_file():
+ result.append({"id": path.name, "path": str(path)})
+ return result
+
+ def preview(self, body):
+ output = self.directory(body["name"])
+ bundle = build_config_bundle(body["answers"], output_directory=output)
+ if body.get("check_files") is True:
+ bundle["validation"] = validate_mappings(yaml.safe_load(bundle["files"]["config.yaml"]),
+ yaml.safe_load(bundle["files"]["deployment.yaml"]), config_path=output / "config.yaml", check_files=True)
+ return bundle
+
+ def save(self, body):
+ with self.lock:
+ output = self.directory(body["name"])
+ bundle = build_config_bundle(body["answers"], output_directory=output)
+ publish_bundle(bundle, output)
+ return {"id": output.name, "path": str(output), "validation": bundle["validation"]}
+
+ def configuration(self, identifier):
+ root = self.directory(identifier)
+ manifest = json.loads((root / "setup-manifest.json").read_text(encoding="utf-8"))
+ answers = {"answers_version": 1, "preset": manifest["preset"],
+ "training": read_mapping(root / "config.yaml"), "deployment": read_mapping(root / "deployment.yaml")}
+ return {"id": identifier, "answers": answers,
+ "files": {name: (root / name).read_text(encoding="utf-8") for name in
+ ("config.yaml", "deployment.yaml", "setup-manifest.json")}}
+
+ def start(self, body):
+ mode = body["mode"]
+ if mode not in {"dry-run", "doctor", "train"}:
+ raise ValueError("Unknown run mode")
+ if mode != "dry-run" and (body.get("confirmed") is not True or os.name != "posix"):
+ raise ValueError("Live jobs require explicit confirmation and a Linux/POSIX controller")
+ with self.lock:
+ identifier = body["configuration"]
+ root = self.directory(identifier)
+ config = self.configuration(identifier)
+ result = validate_mappings(yaml.safe_load(config["files"]["config.yaml"]),
+ yaml.safe_load(config["files"]["deployment.yaml"]), config_path=root / "config.yaml", check_files=True)
+ if result["status"] == "invalid":
+ raise ValueError("; ".join(row["message"] for row in result["checks"] if row["status"] == "failed"))
+ return self.jobs.start(identifier, config, mode)
+
+
+def make_server(console, port=0):
+ class Handler(BaseHTTPRequestHandler):
+ def setup(self):
+ super().setup()
+ self.connection.settimeout(15)
+
+ def log_message(self, *_args):
+ pass
+
+ def respond(self, status, payload, content_type="application/json; charset=utf-8"):
+ data = payload if isinstance(payload, bytes) else json.dumps(payload).encode("utf-8")
+ self.send_response(status)
+ for name, value in {"Content-Type": content_type, "Content-Length": str(len(data)),
+ "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff",
+ "Referrer-Policy": "no-referrer", "X-Frame-Options": "DENY",
+ "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'"}.items():
+ self.send_header(name, value)
+ self.end_headers()
+ self.wfile.write(data)
+
+ def dispatch(self, method):
+ try:
+ host = self.headers.get("Host", "")
+ if urlsplit("http://" + host).hostname not in {"127.0.0.1", "localhost"}:
+ return self.respond(403, {"error": "Loopback Host required"})
+ path = urlsplit(self.path).path
+ if method == "GET" and path in ASSETS:
+ name, mime = ASSETS[path]
+ return self.respond(200, files("harborrl.webui").joinpath("static", name).read_bytes(), mime)
+ if not hmac.compare_digest(self.headers.get("Authorization", ""), "Bearer " + console.token):
+ return self.respond(403, {"error": "Console session expired or not authorized. Reopen the CLI URL."})
+ if method == "GET":
+ if path == "/api/bootstrap":
+ return self.respond(200, console.bootstrap())
+ if path == "/api/jobs":
+ return self.respond(200, console.jobs.list())
+ if path.startswith("/api/jobs/"):
+ return self.respond(200, console.jobs.log(path.removeprefix("/api/jobs/")))
+ if path.startswith("/api/configurations/"):
+ return self.respond(200, console.configuration(path.removeprefix("/api/configurations/")))
+ if method == "POST":
+ if self.headers.get("Origin") != "http://" + host:
+ return self.respond(403, {"error": "Same-origin request required"})
+ if self.headers.get("Content-Type", "").split(";")[0] != "application/json":
+ return self.respond(415, {"error": "JSON body required"})
+ size = int(self.headers.get("Content-Length", "0"))
+ if not 0 < size <= 1048576:
+ return self.respond(413, {"error": "Request must be between 1 byte and 1 MiB"})
+ body = json.loads(self.rfile.read(size))
+ if not isinstance(body, dict):
+ raise TypeError("Request must be an object")
+ if path == "/api/preview":
+ return self.respond(200, console.preview(body))
+ if path == "/api/configurations":
+ return self.respond(201, console.save(body))
+ if path == "/api/jobs":
+ return self.respond(201, console.start(body))
+ if path == "/api/stop":
+ return self.respond(200, console.jobs.stop(body["id"]))
+ if path == "/api/reconcile":
+ if body.get("confirmed") is not True:
+ raise ValueError("Confirm target cleanup before reconciling")
+ return self.respond(200, console.jobs.reconcile(body["id"]))
+ if path == "/api/import":
+ answers = yaml.safe_load(body["text"])
+ if (not isinstance(answers, dict) or type(answers.get("answers_version")) is not int
+ or answers.get("answers_version") != 1):
+ raise ValueError("Import an answers_version: 1 YAML/JSON mapping")
+ if set(answers) - {"answers_version", "preset", "training", "deployment", "sandbox_config"}:
+ raise ValueError("Unknown answer fields")
+ if any(not isinstance(answers.get(key, {}), dict) for key in ("training", "deployment")):
+ raise ValueError("training and deployment answers must be objects")
+ if answers.get("preset") not in {p["id"] for p in PRESETS}:
+ raise ValueError("Unknown deployment preset")
+ return self.respond(200, {"answers": answers})
+ self.respond(404, {"error": "Not found"})
+ except (ValueError, TypeError, KeyError, OSError, AttributeError, yaml.YAMLError) as exc:
+ self.respond(400, {"error": str(exc)})
+
+ def do_GET(self):
+ self.dispatch("GET")
+
+ def do_POST(self):
+ self.dispatch("POST")
+
+ server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
+ server.daemon_threads = True
+ return server
diff --git a/harborrl/webui/static/app.js b/harborrl/webui/static/app.js
new file mode 100644
index 000000000..0a6a5dc0d
--- /dev/null
+++ b/harborrl/webui/static/app.js
@@ -0,0 +1,884 @@
+/* The form edits answers; Python remains the authoritative builder and validator. */
+"use strict";
+const $ = (selector) => document.querySelector(selector);
+const $$ = (selector) => [...document.querySelectorAll(selector)];
+const escapeHtml = (value) =>
+ String(value ?? "").replace(
+ /[&<>"']/g,
+ (c) =>
+ ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
+ c
+ ],
+ );
+const icon = (name) => ``;
+const state = {
+ bootstrap: null,
+ answers: null,
+ step: 0,
+ saved: null,
+ preview: null,
+ file: "config.yaml",
+ view: "configuration",
+ selectedJob: null,
+ logSource: "console",
+ jobs: [],
+ busy: false,
+};
+const fragment = new URLSearchParams(location.hash.slice(1));
+if (fragment.has("token")) {
+ sessionStorage.setItem("harborrl-token", fragment.get("token"));
+ history.replaceState(null, "", location.pathname);
+}
+const token = sessionStorage.getItem("harborrl-token") || "";
+function icons() {
+ lucide.createIcons({ attrs: { "aria-hidden": "true" } });
+}
+function notify(text, success = false) {
+ $("#notice").textContent = text;
+ $("#notice").className = `notice${success ? " success" : ""}`;
+ $("#notice").hidden = false;
+}
+async function api(path, body) {
+ const response = await fetch(path, {
+ method: body === undefined ? "GET" : "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+ });
+ const result = await response.json();
+ if (!response.ok) throw new Error(result.error || "Request failed");
+ return result;
+}
+async function action(callback) {
+ if (state.busy) return;
+ state.busy = true;
+ $("#notice").hidden = true;
+ try {
+ await callback();
+ } catch (error) {
+ notify(error.message);
+ } finally {
+ state.busy = false;
+ updateActions();
+ icons();
+ }
+}
+function valueAt(object, path) {
+ return path.split(".").reduce((value, key) => value?.[key], object);
+}
+function setAt(object, path, value) {
+ const parts = path.split(".");
+ let parent = object;
+ parts.slice(0, -1).forEach((key) => {
+ if (!parent[key] || typeof parent[key] !== "object") parent[key] = {};
+ parent = parent[key];
+ });
+ parent[parts.at(-1)] = value;
+}
+function defaultRoot() {
+ const separator = state.bootstrap.workspace.includes("\\") ? "\\" : "/";
+ return [
+ state.bootstrap.workspace,
+ "configurations",
+ $("#config-name").value,
+ "runs",
+ ].join(separator);
+}
+function read(path) {
+ const supplied = valueAt(state.answers, path);
+ if (supplied !== undefined) return supplied;
+ if (path === "sandbox_config")
+ return valueAt(state.answers, "training.harbor.environment.config");
+ if (path === "deployment.storage.run_root")
+ return valueAt(state.answers, "training.output.root") ?? defaultRoot();
+ if (
+ [
+ "training.deployment.actor_gpus",
+ "training.deployment.rollout_gpus",
+ ].includes(path) &&
+ read("training.deployment.layout") === "colocate"
+ )
+ return read("training.deployment.num_gpus");
+ return valueAt(state.bootstrap.defaults[state.answers.preset], path);
+}
+function changed() {
+ state.saved = null;
+ state.preview = null;
+ $("#draft-state").textContent = "Draft";
+ $("#draft-state").className = "badge";
+ updateActions();
+ renderSummary();
+}
+function deriveWorkers() {
+ if (state.answers.preset === "service-ssh") {
+ delete state.answers.training.harbor?.workers;
+ delete state.answers.training.harbor?.python;
+ }
+}
+function newAnswers(preset = "embedded-local") {
+ return { answers_version: 1, preset, training: {}, deployment: {} };
+}
+function field(path, label, type = "text", options = {}) {
+ const value = read(path),
+ id = path.replaceAll(".", "-");
+ let control;
+ if (options.choices)
+ control = ``;
+ else if (type === "checkbox")
+ control = ``;
+ else
+ control = ``;
+ return `
${type === "checkbox" ? control : ""}${type === "checkbox" ? "" : control}${options.hint ? `${escapeHtml(options.hint)}` : ""}
`;
+}
+const section = (title, content) =>
+ ``;
+function renderForm() {
+ const preset = state.answers.preset,
+ service = preset !== "embedded-local",
+ sandbox = preset === "service-sandbox-local-runner";
+ let html = "";
+ if (state.step === 0) {
+ const presets = [
+ ["embedded-local", "Local", "One machine", "monitor"],
+ ["service-ssh", "SSH workers", "Distributed Docker", "network"],
+ ["service-sandbox-local-runner", "Sandbox", "Controller + GPU", "box"],
+ ];
+ html = `Deployment topology
${service ? "Service" : "Embedded"}${presets.map(([id, label, detail, ico]) => `
`).join("")}
`;
+ html += section(
+ "Controller",
+ field("deployment.manager.source_dir", "Source directory", "text", {
+ full: true,
+ hint: "Path on the controller",
+ required: true,
+ }) +
+ field(
+ "deployment.manager.controller_python",
+ "Controller Python",
+ "text",
+ { full: true, required: true },
+ ) +
+ (service
+ ? field("deployment.manager.control_url", "Control origin", "url", {
+ placeholder: "http://controller.example:49200",
+ required: true,
+ }) +
+ field("deployment.manager.gateway_url", "Gateway origin", "url", {
+ placeholder: "http://controller.example:49184",
+ required: true,
+ })
+ : ""),
+ );
+ html += section(
+ "GPU target",
+ (service
+ ? field("deployment.gpu_target.ssh.host", "SSH destination", "text", {
+ placeholder: "gpu-training",
+ full: true,
+ required: true,
+ })
+ : "") +
+ field("deployment.gpu_target.source_dir", "Source directory", "text", {
+ full: true,
+ required: true,
+ hint: "Path on the GPU machine",
+ }) +
+ field("deployment.gpu_target.train_python", "Training Python", "text", {
+ full: true,
+ required: true,
+ }),
+ );
+ if (preset === "service-ssh")
+ html += ``;
+ else
+ html += section(
+ "Rollout runner",
+ field("training.harbor.python", "Harbor runner Python", "text", {
+ full: true,
+ required: true,
+ }) +
+ (sandbox
+ ? field("sandbox_config", "Sandbox configuration", "text", {
+ full: true,
+ placeholder: "/srv/site/sandbox.yaml",
+ required: true,
+ })
+ : ""),
+ );
+ html += section(
+ "Storage",
+ field(
+ "deployment.storage.run_root",
+ service ? "Shared run root" : "Run root",
+ "text",
+ {
+ full: true,
+ required: true,
+ hint: service
+ ? "Same absolute path on the controller, GPU and workers"
+ : "Path on this machine",
+ },
+ ),
+ );
+ } else if (state.step === 1) {
+ html += section(
+ "Model",
+ field("training.model.args_file", "Model args preset", "text", {
+ required: true,
+ }) +
+ field("training.harness.name", "Harness", "text", {
+ choices: state.bootstrap.catalog.harnesses.map((x) => x.id),
+ }) +
+ field("training.model.checkpoint", "Checkpoint directory", "text", {
+ full: true,
+ required: true,
+ hint: "Path on the GPU target",
+ }) +
+ field("training.model.reference", "Reference checkpoint", "text", {
+ full: true,
+ required: true,
+ }),
+ );
+ html += section(
+ "Tasks & agent",
+ field("training.tasks.catalog", "Task catalog", "text", {
+ full: true,
+ required: true,
+ placeholder: "/srv/inputs/tasks.jsonl",
+ hint: "An existing locked JSON / JSONL catalog on the controller",
+ }) +
+ field("training.harness.profile", "Claude Code profile", "text", {
+ full: true,
+ required: true,
+ placeholder: "/srv/inputs/claude-code.json",
+ }),
+ );
+ html += section(
+ "Gateway fingerprints",
+ field("training.gateway.tokenizer_digest", "Tokenizer SHA-256", "text", {
+ full: true,
+ required: true,
+ }) +
+ field("training.gateway.template_digest", "Template SHA-256", "text", {
+ full: true,
+ required: true,
+ }) +
+ (!service
+ ? field("training.gateway.host", "Bind address") +
+ field("training.gateway.advertised_url", "Advertised origin", "url")
+ : ""),
+ );
+ } else if (state.step === 2) {
+ html += section(
+ "GPU allocation",
+ field("training.deployment.layout", "Layout", "text", {
+ choices: ["colocate", "split"],
+ }) +
+ field("training.deployment.num_gpus", "Total GPUs", "number") +
+ field("training.deployment.actor_gpus", "Actor GPUs", "number") +
+ field("training.deployment.rollout_gpus", "Serving GPUs", "number") +
+ field(
+ "training.deployment.actor_tensor_parallel_size",
+ "Actor tensor parallelism",
+ "number",
+ ) +
+ field(
+ "training.deployment.rollout_gpus_per_engine",
+ "Serving tensor parallelism",
+ "number",
+ ),
+ );
+ html += section(
+ "Optimization",
+ field("training.training.num_rollout", "Rollout batches", "number") +
+ field("training.training.learning_rate", "Learning rate", "text") +
+ field("training.training.save_interval", "Save interval", "number") +
+ field(
+ "training.training.backend_contract",
+ "Algorithm contract",
+ "text",
+ {
+ choices: [
+ ...new Set(
+ state.bootstrap.catalog.algorithms.flatMap(
+ (x) => x.training_backend_contracts,
+ ),
+ ),
+ ],
+ },
+ ),
+ );
+ html += section(
+ "Sampling",
+ field("training.sampling.group_size", "Samples per group", "number") +
+ field(
+ "training.sampling.groups_per_batch",
+ "Groups per batch",
+ "number",
+ ) +
+ field(
+ "training.sampling.max_tokens",
+ "Maximum output tokens",
+ "number",
+ ) +
+ field("training.sampling.max_context", "Context limit", "number") +
+ field("training.sampling.max_attempts", "Maximum attempts", "number") +
+ field(
+ "training.gateway.audited_raw_logprobs",
+ "Audited raw log probabilities",
+ "checkbox",
+ ),
+ );
+ }
+ $("#form-sections").innerHTML = html;
+ $("#form-sections").hidden = state.step === 3;
+ $("#review").hidden = state.step !== 3;
+ if (state.step === 0 && preset === "service-ssh") renderWorkers();
+ $$("[data-path]").forEach((input) =>
+ input.addEventListener("change", () => {
+ const path = input.dataset.path;
+ let value =
+ input.type === "checkbox"
+ ? input.checked
+ : input.type === "number" || path.endsWith("learning_rate")
+ ? input.value === ""
+ ? null
+ : Number(input.value)
+ : input.value;
+ setAt(state.answers, path, value);
+ // Loaded bundles contain both sides of repeated schema fields.
+ const mirrors = {
+ "deployment.manager.control_url": null,
+ "deployment.manager.gateway_url": "training.gateway.advertised_url",
+ "deployment.storage.run_root": "training.output.root",
+ "training.gateway.host": "deployment.manager.bind",
+ "training.gateway.advertised_url": "deployment.manager.gateway_url",
+ };
+ if (mirrors[path] && valueAt(state.answers, mirrors[path]) !== undefined)
+ setAt(state.answers, mirrors[path], value);
+ if (
+ path === "sandbox_config" &&
+ valueAt(state.answers, "training.harbor.environment.config") !==
+ undefined
+ )
+ setAt(state.answers, "training.harbor.environment.config", value);
+ if (
+ path.startsWith("deployment.rollout.workers.") &&
+ state.answers.preset === "service-ssh"
+ ) {
+ deriveWorkers();
+ }
+ if (path === "training.deployment.num_gpus") {
+ if (
+ valueAt(state.answers, "deployment.gpu_target.expected_gpus") !==
+ undefined
+ )
+ setAt(state.answers, "deployment.gpu_target.expected_gpus", value);
+ if (read("training.deployment.layout") === "colocate")
+ ["actor_gpus", "rollout_gpus"].forEach((key) =>
+ setAt(state.answers, `training.deployment.${key}`, value),
+ );
+ }
+ if (path === "training.deployment.layout" && value === "colocate") {
+ ["actor_gpus", "rollout_gpus"].forEach((key) =>
+ setAt(
+ state.answers,
+ `training.deployment.${key}`,
+ read("training.deployment.num_gpus"),
+ ),
+ );
+ }
+ changed();
+ if (path.startsWith("training.deployment.")) renderForm();
+ }),
+ );
+ $$("[data-preset]").forEach((button) =>
+ button.addEventListener("click", () =>
+ action(async () => {
+ if (button.dataset.preset === state.answers.preset) return;
+ if (
+ !(await confirmAction(
+ "Change deployment?",
+ "This starts a new draft for the selected topology. Saved configurations remain available.",
+ "Change topology",
+ ))
+ )
+ return;
+ state.answers = newAnswers(button.dataset.preset);
+ changed();
+ renderForm();
+ }),
+ ),
+ );
+ $("#add-worker")?.addEventListener("click", () => {
+ const workers = state.answers.deployment.rollout?.workers || [];
+ workers.push({
+ id: `worker-${Date.now().toString(36)}`,
+ ssh: { host: "" },
+ source_dir: "",
+ runner_python: "",
+ docker: true,
+ task_roots: [""],
+ });
+ setAt(state.answers, "deployment.rollout.workers", workers);
+ deriveWorkers();
+ changed();
+ renderForm();
+ });
+ renderSummary();
+ updateActions();
+ icons();
+}
+function renderWorkers() {
+ const workers = state.answers.deployment.rollout?.workers || [];
+ $("#worker-fields").innerHTML = workers.length
+ ? workers
+ .map(
+ (worker, index) =>
+ `Worker ${index + 1}
${field(`deployment.rollout.workers.${index}.id`, "ID")}${field(`deployment.rollout.workers.${index}.ssh.host`, "SSH destination")}${field(`deployment.rollout.workers.${index}.source_dir`, "Source directory", "text", { full: true })}${field(`deployment.rollout.workers.${index}.runner_python`, "Runner Python", "text", { full: true })}${field(`deployment.rollout.workers.${index}.task_roots.0`, "Task root", "text", { full: true })}
`,
+ )
+ .join("")
+ : 'No workers configured.
';
+ $$("[data-remove-worker]").forEach((button) =>
+ button.addEventListener("click", () => {
+ workers.splice(Number(button.dataset.removeWorker), 1);
+ deriveWorkers();
+ changed();
+ renderForm();
+ }),
+ );
+}
+function renderSummary() {
+ const service = state.answers.preset !== "embedded-local",
+ sandbox = state.answers.preset === "service-sandbox-local-runner";
+ const nodes = [
+ ["monitor", "Controller", service ? "Service mode" : "Embedded mode"],
+ [
+ "cpu",
+ "GPU target",
+ service
+ ? read("deployment.gpu_target.ssh.host") || "Not configured"
+ : "Local machine",
+ ],
+ [
+ sandbox ? "box" : "network",
+ "Rollout",
+ sandbox
+ ? "Sandbox environment"
+ : service
+ ? `${state.answers.deployment.rollout?.workers?.length || 0} SSH workers`
+ : "Local Docker",
+ ],
+ ];
+ $("#topology").innerHTML = nodes
+ .map(
+ ([ico, title, detail], index) =>
+ `${index ? '' : ""}${icon(ico)}
${title}${escapeHtml(detail)}
`,
+ )
+ .join("");
+ const rows = [
+ ["Model", read("training.model.args_file")],
+ ["Algorithm", "GRPO"],
+ ["GPUs", read("training.deployment.num_gpus")],
+ ["Layout", read("training.deployment.layout")],
+ [
+ "Samples / batch",
+ Number(read("training.sampling.group_size")) *
+ Number(read("training.sampling.groups_per_batch")),
+ ],
+ ["Rollout batches", read("training.training.num_rollout")],
+ ];
+ $("#resource-summary").innerHTML =
+ `${rows.map(([key, value]) => `- ${key}
- ${escapeHtml(value)}
`).join("")}
`;
+ $("#summary-status").textContent = state.saved
+ ? "Saved configuration"
+ : "Unsaved configuration";
+ icons();
+}
+function updateActions() {
+ $("#previous").disabled = state.step === 0 || state.busy;
+ $("#next").hidden = state.step === 3;
+ $("#save").hidden = state.step !== 3;
+ $("#next").disabled = state.busy;
+ $("#save").disabled =
+ state.busy ||
+ !state.preview ||
+ state.preview.validation.status === "invalid" ||
+ !!state.saved;
+ $("#step-description").textContent = `Step ${state.step + 1} of 4`;
+ $$("[data-step]").forEach((button) => {
+ button.classList.toggle(
+ "active",
+ Number(button.dataset.step) === state.step,
+ );
+ button.setAttribute(
+ "aria-current",
+ Number(button.dataset.step) === state.step ? "step" : "false",
+ );
+ });
+ const active = state.jobs.some((job) =>
+ ["running", "stopping", "unknown"].includes(job.state),
+ );
+ $("#dry-run").disabled = !state.saved || state.busy || active;
+ $("#doctor").disabled =
+ !state.saved || state.busy || active || !state.bootstrap?.live_supported;
+ $("#train").disabled =
+ !state.saved || state.busy || active || !state.bootstrap?.live_supported;
+}
+async function preview(checkFiles = false) {
+ state.preview = null;
+ $("#validation-result").textContent = "Checking configuration...";
+ $("#config-preview").textContent = "";
+ try {
+ state.preview = await api("/api/preview", {
+ name: $("#config-name").value,
+ answers: state.answers,
+ check_files: checkFiles,
+ });
+ const result = state.preview.validation;
+ $("#validation-result").innerHTML =
+ `${result.status === "invalid" ? "Changes required" : "Structure valid · runtime checks deferred"}
${result.checks.map((row) => `${icon(row.status === "passed" ? "circle-check" : row.status === "failed" ? "circle-x" : "clock-3")}
${escapeHtml(row.path)}${escapeHtml(row.message)}
`).join("")}`;
+ renderPreview();
+ } catch (error) {
+ $("#validation-result").textContent = error.message;
+ throw error;
+ } finally {
+ updateActions();
+ icons();
+ }
+}
+function renderPreview() {
+ $("#config-preview").textContent = state.preview?.files[state.file] || "";
+ $$("[data-file]").forEach((button) =>
+ button.classList.toggle("active", button.dataset.file === state.file),
+ );
+}
+async function goStep(step) {
+ state.step = step;
+ renderForm();
+ if (step === 3) await preview();
+}
+function showView(view) {
+ state.view = view;
+ $("#configuration-view").hidden = view !== "configuration";
+ $("#runs-view").hidden = view !== "runs";
+ $$("[data-view]").forEach((button) =>
+ button.classList.toggle("active", button.dataset.view === view),
+ );
+ if (view === "runs") renderJobs();
+}
+function renderSaved() {
+ const configurations = state.bootstrap.configurations;
+ $("#saved-list").innerHTML = configurations.length
+ ? configurations
+ .map(
+ (config) =>
+ ``,
+ )
+ .join("")
+ : 'No configurations yet';
+ $$("[data-config]").forEach((button) =>
+ button.addEventListener("click", () =>
+ action(async () => {
+ if (
+ !state.saved &&
+ !(await confirmAction(
+ "Open saved configuration?",
+ "The current draft will be replaced.",
+ "Open configuration",
+ ))
+ )
+ return;
+ const config = await api(
+ `/api/configurations/${encodeURIComponent(button.dataset.config)}`,
+ );
+ state.answers = config.answers;
+ state.saved = config.id;
+ $("#config-name").value = config.id;
+ $("#draft-state").textContent = "Saved";
+ $("#draft-state").className = "badge saved";
+ showView("configuration");
+ await goStep(3);
+ renderSaved();
+ }),
+ ),
+ );
+ icons();
+}
+function download(name, text, type = "text/plain") {
+ const url = URL.createObjectURL(new Blob([text], { type }));
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = name;
+ anchor.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+}
+function confirmAction(title, message, label = "Confirm") {
+ const dialog = $("#confirm-dialog");
+ $("#confirm-title").textContent = title;
+ $("#confirm-message").textContent = message;
+ $("#confirm-action").textContent = label;
+ dialog.returnValue = "cancel";
+ return new Promise((resolve) => {
+ dialog.addEventListener(
+ "close",
+ () => resolve(dialog.returnValue === "confirm"),
+ { once: true },
+ );
+ dialog.showModal();
+ });
+}
+async function startJob(mode) {
+ if (
+ mode !== "dry-run" &&
+ !(await confirmAction(
+ mode === "train" ? "Start training?" : "Run live environment checks?",
+ mode === "train"
+ ? `Configuration: ${state.saved}\nGPU target: ${read("deployment.gpu_target.ssh.host") || "Local machine"}\nGPU budget: ${read("training.deployment.num_gpus")}\n\nTraining runs preflight, then allocates the configured GPU and rollout resources.`
+ : "Doctor executes environment and network checks on the configured machines.",
+ mode === "train" ? "Start training" : "Run doctor",
+ ))
+ )
+ return;
+ const job = await api("/api/jobs", {
+ configuration: state.saved,
+ mode,
+ confirmed: mode !== "dry-run",
+ });
+ state.selectedJob = job.id;
+ showView("runs");
+ await refreshJobs();
+}
+function renderJobs() {
+ $("#run-count").textContent = state.jobs.length;
+ $("#jobs-list").innerHTML = state.jobs.length
+ ? `| CONFIGURATION | MODE | STARTED | STATUS | EXIT |
${state.jobs.map((job) => `${escapeHtml(job.configuration)} ${job.id.slice(0, 8)} | ${escapeHtml(job.mode)} | ${new Date(job.created_at).toLocaleString()} | ${escapeHtml(job.state)} | ${job.exit_code ?? "—"} |
`).join("")}
`
+ : `${icon("activity")}
No runs yet
Your dry-runs, environment checks and training jobs appear here.
`;
+ $$("[data-job]").forEach((row) => {
+ const select = () =>
+ action(async () => {
+ state.selectedJob = row.dataset.job;
+ renderJobs();
+ await loadLog();
+ });
+ row.addEventListener("click", select);
+ row.addEventListener("keydown", (event) => {
+ if (event.key === "Enter") select();
+ });
+ });
+ icons();
+}
+async function loadLog() {
+ if (!state.selectedJob) return;
+ const result = await api(`/api/jobs/${state.selectedJob}`),
+ job = result.job;
+ $("#job-detail").hidden = false;
+ $("#log-selector").hidden = false;
+ $("#job-title").textContent = `${job.configuration} · ${job.mode}`;
+ $("#job-meta").textContent =
+ `${job.state} · ${job.snapshot}${job.message ? ` · ${job.message}` : ""}`;
+ const log = $("#job-log"),
+ atBottom = log.scrollTop + log.clientHeight >= log.scrollHeight - 25;
+ const training = state.logSource === "training";
+ log.textContent =
+ (training ? result.training_text : result.text) ||
+ "Waiting for process output...";
+ if (atBottom) log.scrollTop = log.scrollHeight;
+ $("#log-note").textContent =
+ `${(training ? result.training_truncated : result.truncated) ? "Showing the last 64 KiB. " : ""}${training ? result.training_log_path || "No separate GPU log yet" : job.log_path} · Output: ${job.output_root}`;
+ $("#stop-job").hidden = !["running", "stopping"].includes(job.state);
+ $("#stop-job").disabled = job.state === "stopping";
+ $("#reconcile-job").hidden = job.state !== "unknown";
+}
+async function refreshJobs() {
+ state.jobs = await api("/api/jobs");
+ renderJobs();
+ updateActions();
+ if (state.view === "runs") await loadLog();
+}
+async function importAnswers(text) {
+ const { answers } = await api("/api/import", { text });
+ state.answers = answers;
+ changed();
+ state.step = 0;
+ renderForm();
+ showView("configuration");
+ notify("Answers imported", true);
+}
+async function initialize() {
+ icons();
+ try {
+ state.bootstrap = await api("/api/bootstrap");
+ state.jobs = state.bootstrap.jobs;
+ state.answers = newAnswers();
+ $("#workspace-path").textContent = state.bootstrap.workspace;
+ $("#connection-text").textContent = "Local session";
+ renderForm();
+ renderSaved();
+ renderJobs();
+ if (!state.bootstrap.live_supported)
+ notify(
+ "Configuration and dry-run are available here. Start this console on the Linux controller for live training.",
+ );
+ } catch (error) {
+ $("#connection-text").textContent = "Disconnected";
+ $(".connection").classList.add("offline");
+ notify(error.message);
+ return;
+ }
+ $$("[data-view]").forEach((button) =>
+ button.addEventListener("click", () => showView(button.dataset.view)),
+ );
+ $$("[data-step]").forEach((button) =>
+ button.addEventListener("click", () =>
+ action(() => goStep(Number(button.dataset.step))),
+ ),
+ );
+ $("#previous").addEventListener("click", () =>
+ action(() => goStep(state.step - 1)),
+ );
+ $("#next").addEventListener("click", () =>
+ action(() => goStep(state.step + 1)),
+ );
+ $("#config-name").addEventListener("change", () => {
+ changed();
+ if (state.step === 3) action(() => preview());
+ else renderForm();
+ });
+ $("#check-files").addEventListener("click", () =>
+ action(() => preview(true)),
+ );
+ $$("[data-file]").forEach((button) =>
+ button.addEventListener("click", () => {
+ state.file = button.dataset.file;
+ renderPreview();
+ }),
+ );
+ $("#download-file").addEventListener("click", () => {
+ if (state.preview) download(state.file, state.preview.files[state.file]);
+ });
+ $("#save").addEventListener("click", () =>
+ action(async () => {
+ const saved = await api("/api/configurations", {
+ name: $("#config-name").value,
+ answers: state.answers,
+ });
+ state.saved = saved.id;
+ state.bootstrap.configurations.push(saved);
+ $("#draft-state").textContent = "Saved";
+ $("#draft-state").className = "badge saved";
+ renderSaved();
+ renderSummary();
+ notify(`Saved ${saved.id}`, true);
+ }),
+ );
+ ["dry-run", "doctor", "train"].forEach((mode) =>
+ $("#" + mode).addEventListener("click", () => action(() => startJob(mode))),
+ );
+ $("#new-config").addEventListener("click", () =>
+ action(async () => {
+ if (
+ !(await confirmAction(
+ "New configuration?",
+ "Start a fresh draft?",
+ "New configuration",
+ ))
+ )
+ return;
+ state.answers = newAnswers();
+ $("#config-name").value =
+ `experiment-${state.bootstrap.configurations.length + 1}`;
+ state.step = 0;
+ changed();
+ showView("configuration");
+ renderForm();
+ renderSaved();
+ }),
+ );
+ $("#answers-button").addEventListener("click", () => {
+ $("#answers-error").hidden = true;
+ $("#answers-editor").value = JSON.stringify(state.answers, null, 2);
+ $("#answers-dialog").showModal();
+ });
+ $("#close-answers").addEventListener("click", () =>
+ $("#answers-dialog").close(),
+ );
+ $("#apply-answers").addEventListener("click", () =>
+ action(async () => {
+ try {
+ await importAnswers($("#answers-editor").value);
+ $("#answers-dialog").close();
+ } catch (error) {
+ $("#answers-error").textContent = error.message;
+ $("#answers-error").hidden = false;
+ }
+ }),
+ );
+ $("#mobile-new").addEventListener("click", () => $("#new-config").click());
+ $("#export-answers").addEventListener("click", () =>
+ download("answers.json", $("#answers-editor").value, "application/json"),
+ );
+ $("#import-button").addEventListener("click", () =>
+ $("#import-file").click(),
+ );
+ $("#import-file").addEventListener("change", () =>
+ action(async () => {
+ const file = $("#import-file").files[0];
+ if (file) {
+ if (file.size > 1048576)
+ throw new Error("Answers file must be at most 1 MiB");
+ await importAnswers(await file.text());
+ }
+ $("#import-file").value = "";
+ }),
+ );
+ $("#refresh-jobs").addEventListener("click", () => action(refreshJobs));
+ $("#stop-job").addEventListener("click", () =>
+ action(async () => {
+ if (
+ await confirmAction(
+ "Stop this job?",
+ "Send a graceful stop request through the existing training CLI?",
+ "Stop job",
+ )
+ ) {
+ await api("/api/stop", { id: state.selectedJob });
+ await refreshJobs();
+ }
+ }),
+ );
+ $("#reconcile-job").addEventListener("click", () =>
+ action(async () => {
+ if (
+ await confirmAction(
+ "Confirm target cleanup?",
+ "Only continue after checking that the previous GPU and Sandbox processes have stopped. This releases the interrupted-job block.",
+ "Cleanup confirmed",
+ )
+ ) {
+ await api("/api/reconcile", { id: state.selectedJob, confirmed: true });
+ await refreshJobs();
+ }
+ }),
+ );
+ $("#copy-log").addEventListener("click", () =>
+ download(`${state.selectedJob}.log`, $("#job-log").textContent),
+ );
+ $("#log-source").addEventListener("change", () => {
+ state.logSource = $("#log-source").value;
+ action(loadLog);
+ });
+ setInterval(async () => {
+ if (state.busy) return;
+ try {
+ await refreshJobs();
+ $("#connection-text").textContent = "Local session";
+ $(".connection").classList.remove("offline");
+ } catch {
+ $("#connection-text").textContent = "Disconnected";
+ $(".connection").classList.add("offline");
+ }
+ }, 2500);
+}
+initialize();
diff --git a/harborrl/webui/static/brand.jpg b/harborrl/webui/static/brand.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..e7cd9b29f9659d9d58b22c291a2474403b24c029
GIT binary patch
literal 186959
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 5##j2j}0!=Lom7mh4Lta~#q1YM!0
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 |