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 ==="