Source code for nemo_rl.environments.gym_checkpoint_adapter

# Copyright (c) 2026, NVIDIA CORPORATION.  All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Thin NeMo RL adapter over one NeMo Gym v2 checkpoint coordinator."""

from __future__ import annotations

from collections.abc import Iterable, Iterator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any

from nemo_rl.environments.checkpointing.interfaces import EnvironmentEpisode

if TYPE_CHECKING:
    from nemo_gym._checkpoint.coordination import Participants, PrepareResult
    from nemo_gym.server_utils import ServerClient


# Gym's episode identity is the backend-neutral one the recovery ledger uses.
# ``capture_key`` mirrors Gym's ``EpisodeId.capture_key``.
GymCheckpointEpisode = EnvironmentEpisode


[docs] @dataclass(frozen=True) class GymCheckpointInstance: """Stable identity and filesystem namespace for one logical Gym actor.""" shard_name: str replica_index: int
[docs] def __post_init__(self) -> None: if ( not self.shard_name or self.shard_name in {".", ".."} or "/" in self.shard_name or "\\" in self.shard_name ): raise ValueError( "Gym checkpoint shard_name must be one safe path component" ) if self.replica_index < 0: raise ValueError("Gym checkpoint replica_index must be non-negative")
@property def instance_id(self) -> str: return f"{self.shard_name}/replica-{self.replica_index}"
[docs] def live_capture_dir(self, capture_root: str | Path) -> Path: return Path(capture_root) / "gym-instances" / self.instance_id
[docs] def checkpoint_dir(self, checkpoint_root: str | Path) -> Path: return Path(checkpoint_root) / "gym-instances" / self.instance_id
[docs] @dataclass(frozen=True) class GymCheckpointCommitSummary: """Actor-local Gym state selected by one checkpoint commit.""" exported_episodes: tuple[GymCheckpointEpisode, ...] staging_keys: tuple[str, ...] # The server names of every participant that committed state. Gym restores # only live participants, so a restore must find each of these live. participants: tuple[str, ...] # Each committed episode's staged rows, as Gym's records attribute them. A # row carried over from an earlier attempt belongs to both attempts. staging_keys_by_episode: Mapping[GymCheckpointEpisode, tuple[str, ...]] = field( default_factory=dict )
[docs] def __post_init__(self) -> None: attributed = { key for keys in self.staging_keys_by_episode.values() for key in keys } if attributed != set(self.staging_keys): raise ValueError( "Gym checkpoint staging_keys_by_episode must attribute exactly the " "committed staging keys: " f"unattributed={sorted(set(self.staging_keys) - attributed)[:10]!r}, " f"unlisted={sorted(attributed - set(self.staging_keys))[:10]!r}" )
# Parked agent sessions that are not an agent's own /run episode. They # cannot be restored and cannot finish while the deployment is prepared. unowned_live_episodes: tuple[GymCheckpointEpisode, ...] = ()
[docs] @dataclass(frozen=True) class GymCheckpointPrepareSummary: """Serializable result of preparing one actor's Gym deployment. ``restarts`` are in-flight episodes some participant cannot capture: Gym refuses a commit that names one, and they start over after a crash. """ prepared: bool blockers: dict[str, tuple[str, ...]] restarts: tuple[GymCheckpointEpisode, ...]
[docs] @classmethod def from_result(cls, result: PrepareResult) -> GymCheckpointPrepareSummary: return cls( prepared=result.prepared, blockers={ server: tuple(blockers) for server, blockers in result.blockers().items() }, restarts=tuple( sorted( GymCheckpointEpisode(episode.rollout_id, episode.attempt) for episode in result.restarts() ) ), )
[docs] class GymCheckpointUnavailable(RuntimeError): """Gym's control plane could not complete one checkpoint control call. Raised in place of Gym's ``CoordinationError``, which cannot be pickled and so reaches the driver as an untyped ``UnserializableException``. The participants' failures arrive as text, so this says only that the call failed, not why; nothing RL holds changed, and a later checkpoint may succeed. """
[docs] @contextmanager def _reraise_as_gym_checkpoint_unavailable() -> Iterator[None]: """Turn Gym's unpicklable ``CoordinationError`` into ``GymCheckpointUnavailable``. Every Gym coordination call goes through this, so its failure reaches the driver typed, with Gym's message, instead of as ``UnserializableException``. """ from nemo_gym._checkpoint.coordination import CoordinationError try: yield except CoordinationError as error: raise GymCheckpointUnavailable(str(error)) from error
[docs] class GymCheckpointAdapter: """Delegate checkpoint operations for exactly one Gym deployment to Gym.""" def __init__( self, *, instance: GymCheckpointInstance, client: ServerClient, auth_token: str, ) -> None: self._instance = instance self._client = client self._auth_token = auth_token self._participants: Participants | None = None
[docs] async def discover(self) -> None: from nemo_gym._checkpoint.coordination import discover if self._participants is not None: return with _reraise_as_gym_checkpoint_unavailable(): participants = await discover(self._client, auth_token=self._auth_token) # Only typed environment servers take part in checkpoints, and turn # recovery refuses them at setup; the legacy_agent relay is not a # participant. Commits therefore assume every episode is an agent's own # /run episode, so fail rather than misattribute one an environment # server owns. environment_servers = sorted( member.server_name for member in participants.members if member.kind == "environment" ) if environment_servers: raise RuntimeError( "Gym turn recovery does not support environment-server checkpoint " f"participants yet: {environment_servers!r}. Route these rows through " "a legacy_agent environment server or use target_level='sibling'." ) self._participants = participants
[docs] async def restart_only_servers(self) -> list[str]: """Return the participants whose rollouts start over after a crash. Gym reports their in-flight rollouts as restarts at every checkpoint, so turn-level recovery quietly becomes restart-from-task for them. Each participant declares this as its checkpoint ``mode``, which discovery already returned. """ return sorted( member.server_name for member in self._require_participants().members if member.mode == "restart_only" )
[docs] def _require_participants(self) -> Participants: if self._participants is None: raise RuntimeError( f"Gym checkpoint instance {self._instance.instance_id!r} has not " "discovered its deployment" ) return self._participants
[docs] async def prepare(self, checkpoint_id: str, *, deadline_ts: float) -> PrepareResult: from nemo_gym._checkpoint.coordination import prepare with _reraise_as_gym_checkpoint_unavailable(): return await prepare( self._require_participants(), checkpoint_id, deadline_ts=deadline_ts )
[docs] async def renew(self, checkpoint_id: str, *, deadline_ts: float) -> None: from nemo_gym._checkpoint.coordination import renew with _reraise_as_gym_checkpoint_unavailable(): await renew( self._require_participants(), checkpoint_id, deadline_ts=deadline_ts )
[docs] @staticmethod def _episode_ids(episodes: Iterable[GymCheckpointEpisode]) -> list[Any]: """Convert the RL wire identity to Gym's validated API model.""" from nemo_gym.episode_types import EpisodeId return [ EpisodeId(rollout_id=episode.rollout_id, attempt=episode.attempt) for episode in episodes ]
[docs] def _commit_summary( self, checkpoint_id: str, episode_ids: list[Any], replies: dict[str, dict[str, Any]], ) -> GymCheckpointCommitSummary: """Convert participant replies into an actor-local ownership summary.""" participants = self._require_participants() expected_participants = { participant.server_name for participant in participants.members } if set(replies) != expected_participants: raise RuntimeError( "Gym checkpoint commit returned an unexpected participant set: " f"expected={sorted(expected_participants)!r}, " f"actual={sorted(replies)!r}" ) requested_keys = {episode_id.capture_key for episode_id in episode_ids} # A legacy_agent environment server only relays /run, so it never holds # the episode: it is the agent's own /run episode instead. run_episode_keys: set[str] = set() agent_session_keys: set[str] = set() staging_keys: set[str] = set() keys_by_capture_key: dict[str, set[str]] = {} # Gym's control plane builds these replies; only how they relate to the # request and to each other is checked here. for member in participants.members: reply = replies[member.server_name] capture_keys = set(reply["episode_ids"]) unexpected = capture_keys - requested_keys if unexpected: raise RuntimeError( "Gym checkpoint participant exported episodes outside the " f"requested scope: participant={member.server_name!r}, " f"unexpected={sorted(unexpected)!r}" ) if member.kind == "agent" and capture_keys: agent_session_keys.update(capture_keys) # The committed episodes the agent's own /run drives. own_keys = set(reply["run_episode_ids"]) unexported = own_keys - capture_keys if unexported: raise RuntimeError( "Gym checkpoint agent names /run episodes it did not export: " f"participant={member.server_name!r}, " f"unexported={sorted(unexported)!r}" ) duplicate_owners = run_episode_keys.intersection(own_keys) if duplicate_owners: raise RuntimeError( "Gym checkpoint episode was exported as their own /run episode " f"by multiple agent participants: {sorted(duplicate_owners)!r}" ) run_episode_keys.update(own_keys) if member.kind == "model": staging_keys.update(reply["staging_keys"]) for capture_key, keys in reply["staging_keys_by_episode"].items(): keys_by_capture_key.setdefault(capture_key, set()).update(keys) from nemo_gym.episode_types import EpisodeId def _episodes(capture_keys: set[str]) -> tuple[GymCheckpointEpisode, ...]: return tuple( GymCheckpointEpisode(parsed.rollout_id, parsed.attempt) for parsed in ( EpisodeId.from_capture_key(capture_key) for capture_key in sorted(capture_keys) ) ) # An agent owns only its own /run episodes; discover refuses # environment servers, the other kind of owner. owned_keys = run_episode_keys return GymCheckpointCommitSummary( exported_episodes=_episodes(owned_keys), participants=tuple( sorted(member.server_name for member in participants.members) ), staging_keys=tuple(sorted(staging_keys)), staging_keys_by_episode={ GymCheckpointEpisode(parsed.rollout_id, parsed.attempt): tuple( sorted(keys) ) for parsed, keys in ( (EpisodeId.from_capture_key(capture_key), keys) for capture_key, keys in keys_by_capture_key.items() ) }, unowned_live_episodes=_episodes(agent_session_keys - owned_keys), )
[docs] async def retire( self, checkpoint_id: str, episodes: Iterable[GymCheckpointEpisode], *, deadline_ts: float, ) -> None: from nemo_gym._checkpoint.coordination import retire with _reraise_as_gym_checkpoint_unavailable(): await retire( self._require_participants(), checkpoint_id, self._episode_ids(episodes), deadline_ts=deadline_ts, )
[docs] async def forget( self, checkpoint_id: str, rollout_ids: Iterable[str], *, deadline_ts: float, ) -> None: from nemo_gym._checkpoint.coordination import forget with _reraise_as_gym_checkpoint_unavailable(): await forget( self._require_participants(), checkpoint_id, list(rollout_ids), deadline_ts=deadline_ts, )
[docs] async def commit( self, checkpoint_id: str, checkpoint_root: str | Path, episodes: Iterable[GymCheckpointEpisode], *, deadline_ts: float, ) -> GymCheckpointCommitSummary: from nemo_gym._checkpoint.coordination import commit episode_ids = self._episode_ids(episodes) checkpoint_dir = self._instance.checkpoint_dir(checkpoint_root) with _reraise_as_gym_checkpoint_unavailable(): replies = await commit( self._require_participants(), checkpoint_id, str(checkpoint_dir), episode_ids, deadline_ts=deadline_ts, ) return self._commit_summary(checkpoint_id, episode_ids, replies)
[docs] async def restore( self, checkpoint_id: str, checkpoint_root: str | Path, episodes: Iterable[GymCheckpointEpisode], *, participants: Iterable[str], source_checkpoint_id: str, deadline_ts: float, ) -> None: """Restore ``episodes`` into this deployment. ``participants`` are the server names that committed the checkpoint. Gym restores only live participants, so one missing here would have its state silently dropped; Gym itself fails a live participant that has no saved state. """ from nemo_gym._checkpoint.coordination import restore live = {member.server_name for member in self._require_participants().members} missing = sorted(set(participants) - live) if missing: raise RuntimeError( f"Gym deployment for instance {self._instance.instance_id!r} lacks " f"participants this checkpoint saved state for: {missing!r}. Start " "Gym with these servers to restore, or start without the Gym " "checkpoint." ) with _reraise_as_gym_checkpoint_unavailable(): replies = await restore( self._require_participants(), checkpoint_id, str(self._instance.checkpoint_dir(checkpoint_root)), self._episode_ids(episodes), deadline_ts=deadline_ts, ) expected_participants = { participant.server_name for participant in self._require_participants().members } if set(replies) != expected_participants: raise RuntimeError( "Gym checkpoint restore returned an unexpected participant set: " f"expected={sorted(expected_participants)!r}, " f"actual={sorted(replies)!r}" ) mismatched_sources: dict[str, object] = {} for participant, reply in replies.items(): if not isinstance(reply, Mapping): mismatched_sources[participant] = type(reply).__name__ continue actual_source = reply.get("source_checkpoint_id") if actual_source != source_checkpoint_id: mismatched_sources[participant] = actual_source if mismatched_sources: raise RuntimeError( "Gym checkpoint restore loaded participant state from the wrong " f"checkpoint: expected={source_checkpoint_id!r}, " f"actual={mismatched_sources!r}" )
[docs] async def resume(self, checkpoint_id: str, *, deadline_ts: float) -> None: from nemo_gym._checkpoint.coordination import resume with _reraise_as_gym_checkpoint_unavailable(): await resume( self._require_participants(), checkpoint_id, deadline_ts=deadline_ts )