Conversation
|
/ok to test ab15151 |
terrykong
left a comment
There was a problem hiding this comment.
Review Summary
This PR adds K8s infrastructure for disaggregated RL-Gym deployments, including:
K8sEndpointRegistry— ConfigMap-backed service discovery between RL and Gym clustersstandalone_gym_server— standalone NeMo Gym server for independent deployment- Remote mode in
NemoGym— connect to an external Gym HTTP service instead of spawning local subprocesses - Kind cluster setup scripts, Helm charts, and K8s manifest examples (JobSet + KubeRay approaches)
- Gym submodule bump (
23cdeb38→01a9765f) for disaggregated mode support
Action Items
main. Please rebase and resolve conflicts before merging.
📝 PR description: The PR body is currently the template placeholder. It would be helpful to fill in a description of the motivation, architecture decisions, and test plan — this helps reviewers and future readers understand the context.
📖 Documentation: Per CONTRIBUTING.md, new key features should include a documentation update. This PR introduces a significant new deployment architecture — consider adding a doc page covering the disaggregated mode, K8s prerequisites, and configuration options (env.remote_gym_url, env.disagg_job_id).
Additional Notes (not tied to specific diff lines)
- Internal image references: All example manifests use
nvcr.io/nvidian/nemo-rl:e5a729c-47084432which is only accessible within NVIDIA networks. If these examples are intended for external contributors too, consider adding a comment noting users should substitute their own image. - Missing
nvcr-secretsetup: SETUP.md Quick Start doesn't mention creating thenvcr-secretimagePullSecret that all manifests require. First-time users will hitImagePullBackOff. - Shell script hardening:
create-cluster.shuses|| trueafternvkind cluster createwhich silently swallows all failures (not just the expected/proc/driver/nvidiapatching issue).install-nvkind.shinstalls nvkind at@latestwithout version pinning.
See inline comments for specific code-level findings.
Generated by Claude Code
| import time | ||
| from pathlib import Path | ||
|
|
||
| from kubernetes import client, config |
There was a problem hiding this comment.
kubernetes is imported at module level but isn't declared in pyproject.toml (not in [project.dependencies] or any optional extra). Users importing K8sEndpointRegistry will get an ImportError unless they manually pip install kubernetes.
The disagg-jobset.yaml works around this with a runtime uv pip install kubernetes (line 212), but it would be cleaner to declare it as an optional dependency — e.g., a k8s extra in pyproject.toml.
There was a problem hiding this comment.
Fixed in 0881933. Added k8s extra to pyproject.toml.
| cm.data[key] = value | ||
| self._v1.patch_namespaced_config_map( | ||
| name=self.configmap_name, namespace=self.namespace, body=cm | ||
| ) |
There was a problem hiding this comment.
k8s_endpoint_registry.py:118-126
The read-modify-patch pattern here is susceptible to lost updates under concurrency. If both RL and Gym call set() on the same ConfigMap near-simultaneously, one side may read a stale resourceVersion. The patch_namespaced_config_map call will then return a 409 Conflict, which isn't caught (only 404 is handled in the outer except), so the write silently fails with an unhandled exception.
Consider catching 409 in the outer handler with a retry loop, or using a strategic merge patch that targets only the specific key rather than replacing the full data dict.
There was a problem hiding this comment.
Fixed in 0881933. Added 409 handling on the patch path with retry.
| except ApiException as create_err: | ||
| if create_err.status == 409: | ||
| # Another process created it between our read and create — retry patch. | ||
| self.set(key, value) |
There was a problem hiding this comment.
k8s_endpoint_registry.py:141-144
The recursive self.set(key, value) on a 409 during the fallback create has no depth limit. In a tight race between RL and Gym both hitting this path, this could recurse until stack overflow. A simple iterative retry with a cap (e.g., for attempt in range(3): ...) would be safer.
There was a problem hiding this comment.
Fixed in 0881933. Converted to iterative retry with _max_retries=5. Raises RuntimeError if exhausted.
| url = remote_url.removeprefix("http://").removeprefix("https://") | ||
| if ":" in url: | ||
| host, port_str = url.rsplit(":", 1) | ||
| port = int(port_str.rstrip("/")) |
There was a problem hiding this comment.
The URL parsing using rsplit(":", 1) works for simple host:port inputs but breaks on URLs with path components. For example, http://gym-service:8080/v1 → host="gym-service", port_str="8080/v1" → int("8080/v1".rstrip("/")) raises ValueError.
Consider using urllib.parse.urlparse for robustness:
from urllib.parse import urlparse
parsed = urlparse(remote_url if "://" in remote_url else f"http://{remote_url}")
host = parsed.hostname or remote_url.rstrip("/")
port = parsed.port or 8080There was a problem hiding this comment.
Fixed in 0881933. Using urllib.parse.urlparse now.
| initial_global_config_dict = cfg.get("initial_global_config_dict") or {} | ||
| self.rollout_max_attempts_to_avoid_lp_nan = initial_global_config_dict.pop( | ||
| "rollout_max_attempts_to_avoid_lp_nan", 1 | ||
| ) |
There was a problem hiding this comment.
Two things here:
-
Mutation leak:
.pop("rollout_max_attempts_to_avoid_lp_nan", 1)mutates the caller'sinitial_global_config_dict(potentially the live config fromrun_grpo_nemo_gym.py). Consider using.get()instead of.pop(), or copying the dict first. -
Missing validation: Unlike the colocated path (line 120), there's no
assert >= 1check here. If a user sets this to 0,run_rollouts()silently produces no results (thewhile trial < max_attemptsloop at line 154 is never entered).
There was a problem hiding this comment.
Fixed in 0881933. Changed .pop() to .get() and added assert >= 1 matching the colocated path.
|
|
||
| # Wait for the Gym cluster to register its head server address. | ||
| print("Waiting for Gym head server to register in endpoint registry...") | ||
| remote_gym_url = registry.get("gym_head_server") |
There was a problem hiding this comment.
registry.get("gym_head_server") blocks for up to 600s and raises TimeoutError on failure. This exception isn't caught — it will crash the training job after all setup (Ray init, model loading, etc.) is complete, wasting GPU time.
Consider wrapping this in a try/except with a user-friendly error message, e.g.:
try:
remote_gym_url = registry.get("gym_head_server")
except TimeoutError:
raise RuntimeError(
f"Timed out waiting for Gym cluster to register. "
f"Check that the Gym cluster is running with --job-id={disagg_job_id}"
)There was a problem hiding this comment.
Fixed in 0881933. Wrapped in try/except TimeoutError with a message pointing to the Gym cluster.
| k: _strip_interpolations(v) | ||
| for k, v in d.items() | ||
| if not (isinstance(v, str) and "${" in v) | ||
| } |
There was a problem hiding this comment.
standalone_gym_server.py:125-136
_strip_interpolations silently removes any config key whose value contains ${. This is meant to handle unresolvable OmegaConf interpolations from the full GRPO config, but it could silently drop legitimate values — the server would then start with missing settings and fail later in an opaque way.
Consider logging each dropped key so users can diagnose config issues:
if isinstance(v, str) and "${" in v:
print(f"Dropping unresolvable interpolation: {k}={v}")
continueThere was a problem hiding this comment.
Fixed in 0881933. Now logs each dropped key with its path and value.
| verbs: ["get", "create", "update", "patch", "delete"] | ||
| - apiGroups: ["ray.io"] | ||
| resources: ["rayclusters"] | ||
| verbs: ["get", "delete"] |
There was a problem hiding this comment.
endpoint-registry-rbac.yaml:13-19
This Role grants delete on all ConfigMaps and all RayClusters in the namespace. A few observations:
deleteon ConfigMaps is only needed by the peer-watcher teardown path. The endpoint registry itself only needsget,create,update,patch.deleteon RayClusters is only needed by the peer-watcher sidecar on head pods, but this SA is also used by GPU worker pods (disagg-rayclusters.yaml lines 167, 200) which don't need it.
Consider splitting into two ServiceAccounts — one for head pods (full permissions) and one for workers (ConfigMap read/write only). This follows the principle of least privilege.
There was a problem hiding this comment.
Added a NOTE comment acknowledging this is an example and suggesting production hardening. Kept as-is for simplicity.
| print(f"[peer-watcher] Peer {PEER} {state or 'unreachable'} ({fails}/{MAX_FAIL})", flush=True) | ||
| if fails >= MAX_FAIL: | ||
| teardown(f"Peer {PEER} failed {MAX_FAIL}x") | ||
| continue |
There was a problem hiding this comment.
disagg-rayclusters.yaml:131-140
The continue on line 136 skips the ConfigMap error-flag check (lines 137-140). If the peer cluster is unhealthy and signals an error via ConfigMap simultaneously, the error flag won't trigger teardown until either the peer recovers or MAX_FAIL is reached.
Moving the ConfigMap check before the continue would catch both conditions:
if state in ("failed", "suspended") or (code != 0 and state == ""):
fails += 1
...
if fails >= MAX_FAIL:
teardown(...)
# Check ConfigMap error even when peer is failing
if JOB_ID:
cm = kube(f"/api/v1/...")
if cm.get("data", {}).get("error", ""):
teardown(f"Error via ConfigMap: {cm['data']['error']}")
continueThere was a problem hiding this comment.
Fixed in 0881933. Moved ConfigMap check before the continue in both peer-watcher sidecars.
| - devices: all | ||
| extraMounts: | ||
| - hostPath: /home/terryk/nemo-rl | ||
| containerPath: /workspace/nemo-rl |
There was a problem hiding this comment.
nvkind-config-values-dev.yaml:6-7
Nit: /home/terryk/nemo-rl is a developer-specific path checked into the repo. Other contributors using this file will silently get an empty DirectoryOrCreate mount. Consider adding a comment noting this path must be edited per-user, or adding this file to .gitignore and providing a .example template instead.
There was a problem hiding this comment.
Fixed in 0881933. Added TODO comment noting the path must be edited per-user.
- Add `k8s` optional extra with `kubernetes` dependency to pyproject.toml - Fix read-modify-patch race in K8sEndpointRegistry.set(): handle 409 on both patch and create paths, convert recursive retry to iterative loop with max attempts - Use urllib.parse.urlparse for robust URL parsing in NemoGym remote mode - Fix .pop() caller-dict mutation: use .get() instead, add validation assert matching the colocated code path - Wrap registry.get() TimeoutError with actionable diagnostic message - Log dropped interpolation keys in standalone_gym_server - Add production-hardening comment to example RBAC manifest - Move ConfigMap error check before `continue` in both peer-watcher sidecars so errors aren't skipped during failure-counting - Add TODO comment about hardcoded hostPath in nvkind dev config Signed-off-by: Terry Kong <terryk@nvidia.com>
Local K8s GPU dev environment using nvkind (NVIDIA's kind wrapper): - nvkind cluster setup scripts (install-nvkind.sh, create-cluster.sh) - Custom config template with extraMounts for dev code mounting - Helmfile with kind/prod environments (device plugin vs GPU operator) - KAI scheduler for gang scheduling, KubeRay for RayCluster management - Example manifests: gang-scheduled pods, RayClusters, SFT RayJobs - SETUP.md with prerequisites, quick start, and architecture docs Tested: SFT RayJob (train/loss 4.06 < 5.9), KAI all-or-nothing gang scheduling, two simultaneous 1-GPU SFT jobs. Signed-off-by: Terry Kong <terryk@nvidia.com>
Add optional remote_gym_url to NemoGymConfig. When set, the NemoGym Ray actor connects to an external Gym HTTP service instead of spawning local subprocesses. Colocated mode (default) is unchanged. - nemo_gym.py: split __init__ into remote/colocated paths - run_grpo_nemo_gym.py: support env.remote_gym_url and env.disagg_job_id - Gym submodule: standalone_server.py entry point with K8s endpoint registry integration, use_absolute_ip for cross-pod communication - gym_standalone_config.yaml: example config for standalone server Tested: disaggregated GRPO completed 3 training steps with RL on one RayCluster (2 GPU) and Gym on a separate RayCluster (CPU only). Signed-off-by: Terry Kong <terryk@nvidia.com>
…overy Each (RL, Gym) job pair shares a ConfigMap for dynamic address exchange. Both sides register their IP:port and poll for the peer's address. The ConfigMap has an ownerReference to the RL RayCluster for automatic garbage collection on teardown. - k8s_endpoint_registry.py: create/set/get/get_nowait methods with race condition handling (409 retry) and proper error propagation - endpoint-registry-rbac.yaml: ServiceAccount + Role + RoleBinding - disagg_rl_raycluster.yaml: RL cluster with serviceAccountName - disagg_gym_raycluster.yaml: Gym cluster with serviceAccountName Tested: ConfigMap CRUD verified in-cluster, bidirectional URL exchange between RL and Gym clusters confirmed working. Signed-off-by: Terry Kong <terryk@nvidia.com>
When RL and Gym run on separate RayClusters, either cluster failing or being deleted triggers teardown of both clusters to release resources. - peer-watcher.py: pure Python sidecar (no deps beyond stdlib), deployed as a ConfigMap volume mount on each head pod - Monitors peer RayCluster status via K8s API (polls every 10s) - Tears down after MAX_PEER_FAILURES (default 3) consecutive failures - Also monitors ConfigMap "error" key for application-level error signaling - Handles transient K8s API errors as failures (not false-healthy) - Added signal_error() to K8sEndpointRegistry - Updated disagg manifests with peer-watcher sidecar containers - Updated RBAC with "delete" verb for rayclusters Tested: deleting either cluster triggers teardown of both within ~10s. Signed-off-by: Terry Kong <terryk@nvidia.com>
…share configs - Kyverno policy: RayCluster/RayJob must have kai.scheduler/queue label. Validates at CRD level (not pod) since KubeRay operator creates pods. Optional Policy 2 for user→queue access control via ConfigMap. - kube-prometheus-stack: Prometheus + Grafana for fairshare monitoring. Pre-built Grafana dashboard showing GPU allocation vs fair share, preemption events, and scheduling latency per queue. - ServiceMonitors for KAI scheduler, binder, and queue-controller. - Example queue configs: - kai-queue.yaml: 2-GPU kind cluster (2 teams, equal quotas) - kai-queue-prod.yaml: 256-GPU prod (3 departments, 6 teams) - preemptMinRuntime: 4h (protect long training runs from priority preemption) - reclaimMinRuntime: 15m (fast fairness reclaim of over-quota resources) - SETUP.md: fairshare docs, preempt vs reclaim explanation, Grafana access. Tested: Kyverno rejects RayCluster without queue label, accepts with. Team A 2-GPU job reclaimed when Team B submitted to its guaranteed quota. Signed-off-by: Terry Kong <terryk@nvidia.com>
- Upgrade KAI scheduler v0.13.4 → v0.14.0 (adds Ray topology-aware
scheduling, segment-size annotation support for PyTorchJob)
- Update chart URL from NVIDIA/KAI-Scheduler to kai-scheduler/KAI-Scheduler
- Fix Grafana dashboard metric names (add kai_ prefix to match actual
Prometheus metric names). Verified: Grafana queries return live data.
- New: extensions/k8s_cli/ — standalone Python CLI (pip installable):
- nrl-k8s fairshare — show queue config (quota, limit, weight, priority)
- nrl-k8s occupancy — show GPU allocation per node and per queue
- nrl-k8s submit — submit gang-scheduled RayJob with optional
--segment-size for topology-aware scheduling
- 6 unit tests (mocked K8s API), all passing
- Add TODO for NVL72 topology testing with links to relevant PRs/issues
Tested: KAI v0.14.0 gang scheduling works, CLI commands verified against
live cluster, Grafana dashboard loads and queries return data.
Signed-off-by: Terry Kong <terryk@nvidia.com>
- Merge disagg_rl_raycluster.yaml + disagg_gym_raycluster.yaml into single disagg-rayclusters.yaml (always deployed together) - Inline peer-watcher Python script directly in sidecar container args (eliminates ConfigMap setup step, each deployment is self-contained) - Remove 7 redundant workload YAMLs (sft_rayjob, kai_scheduled_*, raycluster-blocker, standalone peer-watcher.py) - Update SETUP.md: simplified quick start, updated architecture tree, removed ConfigMap peer-watcher setup step 15 files → 8 files in examples/. Infrastructure configs unchanged. Tested: inlined peer-watcher works — deleting either cluster triggers teardown of both within 10s. Signed-off-by: Terry Kong <terryk@nvidia.com>
- Remove extensions/k8s_cli/ (not needed for now) - Rename queues: org → root-org, priority-team → high-prio, community → low-prio - Simplify Kyverno policy comments - Update SETUP.md to remove CLI section Signed-off-by: Terry Kong <terryk@nvidia.com>
Not needed right now — queue enforcement can be added back later if required. Signed-off-by: Terry Kong <terryk@nvidia.com>
Signed-off-by: Terry Kong <terryk@nvidia.com>
Signed-off-by: Terry Kong <terryk@nvidia.com>
Allows iterating on the server without committing to the Gym repo. The script imports from nemo_gym at runtime (same container image). Run with: uv run --extra nemo_gym python -m nemo_rl.distributed.standalone_gym_server Signed-off-by: Terry Kong <terryk@nvidia.com>
Three workload deployment patterns: 1. rayjob-monolithic.yaml — single-cluster RayJob (1 GPU, KubeRay) 2. disagg-rayclusters.yaml — two KubeRay RayClusters + peer-watcher 3. disagg-jobset.yaml — single JobSet with native failure/startup policies Also adds JobSet controller (v0.11.1) to the helmfile. Signed-off-by: Terry Kong <terryk@nvidia.com>
Shows the full Ray cluster pattern for Gym: separate head and worker pods within the JobSet, with dependsOn ordering and DNS discovery. Signed-off-by: Terry Kong <terryk@nvidia.com>
KAI gang-schedules all pods in a JobSet together (one PodGroup with minMember=total pods). This deadlocks with dependsOn: KAI waits for all pods to exist, but JobSet won't create dependent pods until the head is Ready. Fix: drop dependsOn, use init containers that poll ray health-check (same pattern KubeRay uses). Tested: all 6 pods schedule, init containers wait for heads, driver submits a Ray job successfully, successPolicy triggers on driver exit 0, failurePolicy tears down everything on gym-head crash. Signed-off-by: Terry Kong <terryk@nvidia.com>
Signed-off-by: Terry Kong <terryk@nvidia.com>
- Add git safe.directory for Gym submodule (uv build fails otherwise) - Add uv pip install kubernetes (needed by endpoint registry) - Increase readiness probe failureThreshold (uv install takes time) Signed-off-by: Terry Kong <terryk@nvidia.com>
Replace --working-dir and --runtime-env-json with a simple cd wrapper. Both --working-dir and runtime_env.working_dir cause Ray to zip and upload the entire directory to GCS, which is extremely slow for large repos (1GB+). Since the code is already on all nodes via hostPath, wrapping the entrypoint with cd avoids the upload entirely. Before: ray job submit --working-dir /workspace/nemo-rl -- python ... → scans entire tree, uploads 1GB+ to GCS, takes minutes After: ray job submit -- bash -c "cd /workspace/nemo-rl && python ..." → instant submission, no upload Signed-off-by: Terry Kong <terryk@nvidia.com>
…pefail - Use timestamp-based submission_id to avoid GCS collision across redeploys - Disable wandb (not configured in kind dev cluster) - Add set -eo pipefail for proper exit code propagation through tee - Persist driver logs to hostPath for post-mortem debugging Tested: GRPO training loads config, connects to Ray cluster, loads datasets, initializes compute cluster. Fails with "Not enough GPUs" (expected — kind cluster has 2 GPUs, config expects 8). Signed-off-by: Terry Kong <terryk@nvidia.com>
- Add active development disclaimer (GitHub admonition) - Add production guidance (adapt manifests, use Terraform, not helmfile) - Document colocated vs disaggregated architecture with diagrams - Compare KubeRay RayClusters vs JobSet for disagg deployment - Explain why ConfigMap is still needed for JobSet (vLLM URL exchange) - Document dependsOn + KAI deadlock and init container workaround - Add local kind testing instructions - Add comparison table (failure cascading, gang scheduling, discovery) Signed-off-by: Terry Kong <terryk@nvidia.com>
Add all parallelism overrides needed for Qwen3-0.6B on a 2-GPU cluster: - tensor_model_parallel_size=1, pipeline=1, expert=1, context=1 - sequence_parallel=false (requires TP>1) - colocated.enabled=false, gpus_per_node=1 - max_new_tokens=512, max_total_sequence_length=512 - max_num_steps=2 for quick smoke testing Tested: GRPO initializes vLLM workers, captures CUDA graphs, starts Megatron LM workers. Fails at k8s_endpoint_registry import because the container image predates the tk/infra branch — the hostPath mount has newer code than the baked-in worker venvs. Will work with a matching container build. Signed-off-by: Terry Kong <terryk@nvidia.com>
Signed-off-by: Terry Kong <terryk@nvidia.com>
Mount the local nemo-rl source at both /workspace/nemo-rl and /opt/nemo-rl. The container's editable install points to /opt/nemo-rl, so this ensures all imports (Ray job driver, worker venvs) use the same code from the hostPath mount. Tested: GRPO setup completes (471s), vLLM workers initialized with CUDA graphs, Megatron workers started, endpoint registry created and vLLM URLs published. Full disagg E2E validated up to Gym handshake. Signed-off-by: Terry Kong <terryk@nvidia.com>
- Add `k8s` optional extra with `kubernetes` dependency to pyproject.toml - Fix read-modify-patch race in K8sEndpointRegistry.set(): handle 409 on both patch and create paths, convert recursive retry to iterative loop with max attempts - Use urllib.parse.urlparse for robust URL parsing in NemoGym remote mode - Fix .pop() caller-dict mutation: use .get() instead, add validation assert matching the colocated code path - Wrap registry.get() TimeoutError with actionable diagnostic message - Log dropped interpolation keys in standalone_gym_server - Add production-hardening comment to example RBAC manifest - Move ConfigMap error check before `continue` in both peer-watcher sidecars so errors aren't skipped during failure-counting - Add TODO comment about hardcoded hostPath in nvkind dev config Signed-off-by: Terry Kong <terryk@nvidia.com>
The standalone_gym_server has been moved to nemo_rl/distributed/ so the Gym submodule change from tk/standalone-server is no longer needed. The standalone server could potentially be upstreamed to Gym in the future, but for now it lives in nemo-rl since it's highly in flux. Signed-off-by: Terry Kong <terryk@nvidia.com>
|
Superseded by #2321 which includes all the infra work from this branch plus nrl-k8s CLI improvements (dev pod, DRA auto-management, per-user cluster names, stale RayJob detection). |
- Add `k8s` optional extra with `kubernetes` dependency to pyproject.toml - Fix read-modify-patch race in K8sEndpointRegistry.set(): handle 409 on both patch and create paths, convert recursive retry to iterative loop with max attempts - Use urllib.parse.urlparse for robust URL parsing in NemoGym remote mode - Fix .pop() caller-dict mutation: use .get() instead, add validation assert matching the colocated code path - Wrap registry.get() TimeoutError with actionable diagnostic message - Log dropped interpolation keys in standalone_gym_server - Add production-hardening comment to example RBAC manifest - Move ConfigMap error check before `continue` in both peer-watcher sidecars so errors aren't skipped during failure-counting - Add TODO comment about hardcoded hostPath in nvkind dev config Signed-off-by: Terry Kong <terryk@nvidia.com>
Signed-off-by: Terry Kong <terryk@nvidia.com>
Signed-off-by: Terry Kong <terryk@nvidia.com>
Signed-off-by: Terry Kong <terryk@nvidia.com>
Signed-off-by: Terry Kong <terryk@nvidia.com>
Signed-off-by: Terry Kong <terryk@nvidia.com>
Signed-off-by: Terry Kong <terryk@nvidia.com>
Signed-off-by: Terry Kong <terryk@nvidia.com>
Signed-off-by: Terry Kong <terryk@nvidia.com>
Signed-off-by: Terry Kong <terryk@nvidia.com>
Signed-off-by: Terry Kong <terryk@nvidia.com>
Signed-off-by: Terry Kong <terryk@nvidia.com>
Signed-off-by: Terry Kong <terryk@nvidia.com>
Signed-off-by: Terry Kong <terryk@nvidia.com>
Signed-off-by: Terry Kong <terryk@nvidia.com>
Signed-off-by: Terry Kong <terryk@nvidia.com>
Signed-off-by: Terry Kong <terryk@nvidia.com>
Signed-off-by: Terry Kong <terryk@nvidia.com>
Signed-off-by: Terry Kong <terryk@nvidia.com>
Signed-off-by: Terry Kong <terryk@nvidia.com>
Signed-off-by: Terry Kong <terryk@nvidia.com>
Signed-off-by: Terry Kong <terryk@nvidia.com>
Signed-off-by: Terry Kong <terryk@nvidia.com>
Summary
Add K8s infrastructure for deploying nemo-rl GPU workloads:
K8sEndpointRegistryfor service discovery,remote_gym_urlin NemoGymConfig, standalone Gym server moved tonemo_rl/distributed/Architecture
Two deployment patterns for disaggregated RL+Gym:
failurePolicyand init containers (no peer-watcher needed)Both use a ConfigMap endpoint registry for dynamic vLLM URL exchange.
Test plan