File size: 8,156 Bytes
f0273c6 a7525f2 f0273c6 a7525f2 f0273c6 a7525f2 f0273c6 9cd9f57 f0273c6 ea9b383 73712c7 ea9b383 73712c7 f0273c6 a7525f2 8067c88 a7525f2 f0273c6 ea9b383 f0273c6 a7525f2 ea9b383 a7525f2 f0273c6 ea9b383 f0273c6 e5f301b 231d3f3 e5f301b f0273c6 f4740f8 f0273c6 81a1081 a7525f2 f0273c6 81a1081 f0273c6 ea9b383 f0273c6 e7168a9 f0273c6 a7525f2 e7168a9 ea9b383 f0273c6 c511323 81a1081 f0273c6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 | #!/usr/bin/env bash
# Single-job entrypoint for HF Jobs (2-GPU flavor, e.g. h200x2).
# Runs all three processes in one container:
# GPU 1: vLLM policy server (serve_policy.sh)
# CPU: OpenEnv Harbor env server (serve_env.sh)
# GPU 0: TRL AsyncGRPOTrainer (train_lora.py)
# Sandboxes: Harbor's hf-sandbox backend — each rollout boots an HF Jobs
# cpu-basic machine under the same HF token. No Daytona/E2B account needed.
#
# Expected env (via `hf jobs run --secrets/--env`):
# HF_TOKEN (secret) hub downloads, checkpoint pushes, sandbox Jobs
# ADAPTER_REPO (env) Hub repo that receives adapter checkpoints
# MAX_STEPS (env) 20 for the smoke run, 1000 for the full run
# SANDBOX (env) hf-sandbox (default) | daytona | e2b
set -euo pipefail
cd "$(dirname "$0")"
echo "=== [setup] git (vllm image has none) ==="
apt-get update -qq && apt-get install -y -qq --no-install-recommends git ca-certificates
# NOTE: openenv pulls fastmcp 3.x -> mcp 1.x, which conflicts with vllm 0.32's
# mcp>=2. pip keeps mcp 1.x and only warns; if vllm serve crashes on an mcp
# import, pin an older image tag (vllm>=0.22 suffices for TRL's weight transfer).
echo "=== [setup] packages ==="
# vllm + torch come from the base image (vllm/vllm-openai). Per TRL docs,
# transformers>=5.2.0 may need --no-deps next to vllm's pinned stack.
python3 -m pip install --quiet 'transformers>=5.2.0' --no-deps
# trl is pinned to 1.15.0: 1.16.0 made tree packing unconditional in
# AsyncGRPOTrainer and rejects hybrid models (LFM2.5 has conv layers). 1.15.0
# has HarnessRolloutWorker (#6947) but predates tree packing.
python3 -m pip install --quiet \
'trl[peft]==1.15.0' \
'openenv[harbor]' \
'harbor[hf-sandbox]>=0.24.0' \
'kernels==0.16.0' \
math-verify tomli-w \
aiohttp
echo "=== [setup] OpenEnv repo (harbor_env lives in envs/, not the wheel) ==="
git clone --depth 1 https://github.com/huggingface/OpenEnv.git /opt/OpenEnv
SITE=$(python3 -c "import site; print(site.getsitepackages()[0])")
echo "/opt/OpenEnv/envs" > "$SITE/openenv_examples.pth"
echo "=== [setup] pinned tasks (1,000 train / 250 test, hash-checked) ==="
python3 prepare_tasks.py --output /app/prepared
echo "=== [check] imports ==="
python3 - <<'EOF'
import trl, openenv, harbor_env # noqa: F401
from trl.experimental.async_grpo.openenv_harness import HarnessRolloutWorker # noqa: F401
print("trl", trl.__version__, "| openenv", openenv.__version__)
EOF
echo "=== [patch] opencode seam: also send the capture session as x-api-key ==="
# The HF Jobs proxy strips Authorization. The capture proxy (sessions.py) also
# accepts x-api-key / x-goog-api-key, and OpenCode's @ai-sdk/openai-compatible
# provider supports a custom headers option — so send the session id both ways.
python3 - <<'EOF'
import openenv.harbor.seams as seams
path = seams.__file__
src = open(path).read()
# Target only the _opencode seam: its apiKey/timeout pair at 24-space indent is
# unique (other harnesses share the strings at different indents).
old = '"apiKey": session,\n "timeout": 600_000,'
new = ('"apiKey": session,\n'
' "headers": {"x-api-key": session},\n'
' "timeout": 600_000,')
assert src.count(old) == 1, f"opencode seam not found in {path}"
open(path, "w").write(src.replace(old, new))
print("patched", path)
EOF
echo "=== [start] vLLM policy server (GPU 1) ==="
CUDA_VISIBLE_DEVICES=1 VLLM_SERVER_DEV_MODE=1 \
vllm serve "${MODEL:-LiquidAI/LFM2.5-2.6B}" \
--port 8001 \
--max-model-len 32768 \
--return-tokens-as-token-ids \
--logprobs-mode processed_logprobs \
--enable-auto-tool-choice \
--tool-call-parser lfm2 \
--weight-transfer-config '{"backend":"nccl"}' &
VLLM_PID=$!
echo "=== [start] env server (CPU) ==="
# Tunnel-free proxy exposure: with SPACE_HOST set, OpenEnv mounts the capture
# proxy at /capture on the env server itself, reached through the Jobs proxy at
# https://<JOB_ID>--8000.hf.jobs — no gradio tunnel to die mid-run
# (that failure stalled the first full run silently for 13h).
export SPACE_HOST="${JOB_ID}--8000.hf.jobs"
echo "capture proxy public base: https://${SPACE_HOST}/capture"
PREPARED=/app/prepared bash serve_env.sh &
ENV_PID=$!
echo "=== [start] watchdog (fail the job if no training step for 30 min) ==="
(
while true; do
sleep 300
if [ -f /tmp/rl-heartbeat ]; then
age=$(( $(date +%s) - $(stat -c %Y /tmp/rl-heartbeat) ))
if [ "$age" -gt 1800 ]; then
echo "WATCHDOG: no training step for ${age}s — rollout path is dead, failing the job"
pkill -f train_lora.py || true
sleep 30
exit 1
fi
fi
done
) &
WATCHDOG_PID=$!
echo "=== [wait] servers ==="
python3 - <<'EOF'
import time, urllib.request
for name, url in [("vllm", "http://127.0.0.1:8001/v1/models"),
("env", "http://127.0.0.1:8000/health")]:
for i in range(360): # up to 30 min for model load
try:
urllib.request.urlopen(url, timeout=5)
print(name, "ready")
break
except Exception:
time.sleep(5)
else:
raise SystemExit(f"{name} server never became ready")
EOF
echo "=== [resume] latest pushed checkpoint, if any ==="
# Optimizer shards are not uploaded, so resume restores the policy, scheduler,
# task position (rollout_state.json) and RNG of the sampler — with a fresh
# optimizer state. Close enough for RL.
RESUME_FROM=""
if [ -n "${ADAPTER_REPO:-}" ]; then
RESUME_FROM=$(python3 - <<'EOF'
import os, re
from huggingface_hub import HfApi, snapshot_download
repo = os.environ.get("ADAPTER_REPO", "")
try:
files = HfApi().list_repo_files(repo)
except Exception:
files = []
ckpts = sorted(
{f.split("/")[0] for f in files if f.startswith("checkpoint-") and f.endswith("adapter_config.json")},
key=lambda s: int(s.rsplit("-", 1)[1]),
)
if ckpts:
path = snapshot_download(repo, allow_patterns=[f"{ckpts[-1]}/*"])
print(os.path.join(path, ckpts[-1]))
EOF
)
fi
echo "RESUME_FROM=${RESUME_FROM:-<fresh start>}"
echo "=== [train] GPU 0, MAX_STEPS=${MAX_STEPS:-1000} ==="
CUDA_VISIBLE_DEVICES=0 \
VLLM_URL=http://127.0.0.1:8001 \
ENV_URL=http://127.0.0.1:8000 \
TRAIN_SPLIT=/app/prepared/datasets/train \
RESUME_FROM="$RESUME_FROM" \
python3 train_lora.py
if [ "${SKIP_EVAL:-0}" != "1" ] && [ -n "${ADAPTER_REPO:-}" ]; then
echo "=== [eval] restarting vLLM with the trained adapter (GPU 1) ==="
kill $VLLM_PID 2>/dev/null || true; sleep 10
# Late-bind the eval code: a fix uploaded during training still lands here.
mkdir -p /app-eval
hf download "${CODE_REPO:-SaylorTwift/lfm-opencode-lora-rl-code}" \
--local-dir /app-eval --include "eval_adapter.py" || cp /app/eval_adapter.py /app-eval/
CUDA_VISIBLE_DEVICES=1 \
vllm serve "${MODEL:-LiquidAI/LFM2.5-2.6B}" \
--port 8001 \
--max-model-len 32768 \
--return-tokens-as-token-ids \
--logprobs-mode processed_logprobs \
--enable-auto-tool-choice \
--tool-call-parser lfm2 \
--enable-lora --max-lora-rank 32 \
--lora-modules "trained=${ADAPTER_REPO}" &
VLLM_PID=$!
python3 - <<'EOF'
import time, urllib.request
for i in range(360):
try:
urllib.request.urlopen("http://127.0.0.1:8001/v1/models", timeout=5)
print("vllm ready")
break
except Exception:
time.sleep(5)
else:
raise SystemExit("vllm never became ready for eval")
EOF
echo "=== [eval] 250 held-out tasks x {base, trained} ==="
N_TASKS=250 OUT=/app/eval_results_final.json ADAPTER_NAME=trained \
python3 /app-eval/eval_adapter.py
python3 - <<'EOF'
import os
from huggingface_hub import HfApi
repo = os.environ.get("ADAPTER_REPO")
if repo:
HfApi().upload_file(path_or_fileobj="/app/eval_results_final.json",
path_in_repo="eval/final_results.json",
repo_id=repo)
print("uploaded to", repo)
EOF
fi
kill $VLLM_PID $ENV_PID $WATCHDOG_PID 2>/dev/null || true
echo "=== done ==="
|