Why turn-level snapshots stall, then end the run, with most Gym agents

NVIDIA-NeMo/RL #4500 (Gym turn-level rollout recovery). RL permalinks pin head 341b9ad7; Gym permalinks pin the submodule commit 120d516f.

Before
No turn-level recovery. A crash restarted every unfinished Gym rollout from its prompt.
This PR
Adds rollout_recovery.target_level: turn: each periodic snapshot parks Gym at a turn boundary and saves it.
Status
Open β€” not fixed in this PR. It works only when every Gym agent and resources server can save its sessions. The proposed fix is a setting: fail at setup, or best effort, where rollouts on servers that can save keep turn recovery and the rest restart from the prompt. Tracked in the PR review.

Four pages on this PR, one question each. This one: which Gym setups can use turn.

Background. A Gym deployment has an agent (runs the multi-turn loop), a policy model server (RL's vLLM behind Gym), and resources servers (tools and state). To save a snapshot, Gym asks every server to prepare: stop taking work and reach a state it can write to disk. A server that cannot save a half-done rollout is restart-only. Dotted words have a short explanation: hover or tap them.

Gym prepares the servers one stage at a time, model before agent (PREPARE_ORDER). Once prepared, the model holds every reply until resume (release_response), and RL never turns on Gym's generation cuts, so nothing lets a call finish early (checkpoint_generation_cuts: False). A restart-only agent counts every running /run as a blocker (readiness), and those runs are waiting for the model's held replies. So the agent can never become ready. RL treats that "not ready" as a passing problem (coordinator L89) and tries again at the next snapshot.

One snapshot attempt, two Gym setups

Example: rollout s1 has finished turn 1 and is in the middle of turn 2 when the snapshot starts.

Works: simple_agent + exported resources server (the setup the functional test uses) /run gatecloses model: closes,holds s1's reply agent: parks s1 atnearest boundary resources: savess1's tool state commit β†’ resumesnapshot saved simple_agent app.py:132 workplace app.py:124 Stalls: any other agent, or a restart_only resources server /run gatecloses model: holdss1's turn-2 reply agent: s1's /runis a blocker wait up to300 s not ready β†’ resumesnapshot skipped s1 waits on its held turn-2 reply, so s1 blocks prepare model.py:309-314 agent.py:421-423 gym_prepare_timeout_s single_controller.py:5075 Repeated at every snapshot interval attempt 1 βœ— (300 s) attempt 2 βœ— (300 s) attempt 3 βœ— (300 s) run stops: "failed 3 consecutive times" single_controller.py:5101-5108 max_consecutive_failures = 3 During each attempt, RL sends no new /run and every live rollout sits parked. Before the first train step, a required snapshot fails the run on attempt 1 ( L5088 ). can save its sessions prepared, holding work restart-only: blocks prepare

How many Gym setups hit this: only simple_agent can save its sessions (default False; 1 of 57 agents at this Gym commit). Resources servers default to restart-only (checkpoint_mode, what it means), and 8 of 146 opt out. A restart-only resources session is live from seed to verify, so it blocks the same way. The functional test passes because Workplace is one of the setups that works. Nothing at startup or in the docs warns about the others.

πŸ”΄ review comment β€” gym_checkpoint_adapter.py:208

In the setup that works: where s1 parks, and what the snapshot keeps

simple_agent has two park points (Gym calls them boundaries) in every turn: right after the model's reply (app.py:398) and after that turn's tools return (app.py:482). There is none in the middle of a tool call. So where s1 parks depends on what it is doing in turn 2:

s1 in turn 2 is…Where it parksWhat commit saves for s1After resume, no crashAfter a crash and restore
waiting on the model (the usual case) End of turn 1. Gym does not wait: the call keeps running on vLLM, and its reply is held (model.py:8-13) Turn 1, model reply and tools. The turn-2 call's tokens are left out. Gets the held reply and runs turn 2's tools. Nothing is redone. Turn 2's model call is sent again and generated from scratch.*
receiving the model's reply (already streaming) Turn 2, after the reply, before its tools. A reply that started sending is allowed to finish. Turn 1, plus turn 2's reply. Tools not run yet. Runs turn 2's tools. Runs turn 2's tools from the saved reply.
running a tool End of turn 2. Gym waits for every tool in that turn to return, inside the 300 s prepare limit. Turns 1 and 2, model replies and tools. Goes on to turn 3. Goes on to turn 3.

The tool server saves its state at commit, after every agent has parked, so no tool call is in flight when it saves. Its saved state always matches the turn the agent parked at. The second row happens only if Gym has already asked agents to park when the reply arrives; otherwise s1 starts its tools and ends up in the third row. * Gym can keep a call's partly generated tokens (a "generation cut"), but RL does not hook that up in this PR.

What would fix it β€” proposed, not in the PR

Where the fix lands: Gym and RL. Gym, in the unmerged branch this PR already pins: restart-only servers say so in their status, and in best-effort mode they stop blocking prepare (a–c below). RL: a new rollout_recovery.allow_restart_only_gym_servers setting, a check in _spinup_gym, and a snapshot that accepts rollouts Gym could not save.

Nothing below is implemented or run. The setting only means something when target_level needs Gym saves (turn here, prefix in #4506), so config validation rejects it with sibling or prompt_group. Its two values:

Gym: what stops the stall in best-effort mode. Example: s1 runs on simple_agent, s2 on a restart-only agent, and a snapshot starts.

Gym changeTodayWith the change
a. Report, don't block s2's /run is a prepare blocker (agent.py:421-423; same for a restart-only resources server, resources.py:155-165), so prepare waits 300 s s2 is reported as unsaved; prepare is ready once s1 parks. A tool call already running still finishes first.
b. Hold, don't refuse s2's next model or tool call gets 409 admission_closed (model.py:8). simple_agent parks on a 409; other agents cannot, so s2 would fail. The call waits until resume, the way Gym already holds MCP tool calls (resources.py:251-253).
c. Save all or nothing Not reached: the snapshot already failed. Commit leaves s2 out of every server's save, agent and tools together, so a restored rollout never has its turn without its tool state.

Gym also needs RestartOnlyAgentParticipant.status_extra to return {"restart_only": True}, so setup can find these agents. Resources servers already report their mode.

RL: the setting and the setup check.

@@ RL nemo_rl/algorithms/single_controller_utils/config.py L718-L719 Β· RolloutRecoveryConfig
     # Seconds Gym gets to park its in-flight steps; see the exemplar YAML.
     gym_prepare_timeout_s: Annotated[float, Field(gt=0)] = 300.0
+    # Needs a target_level that saves Gym state. false: a restart-only Gym server
+    # fails setup. true: its rollouts restart from the prompt after a crash.
+    allow_restart_only_gym_servers: bool = False

@@ RL nemo_rl/environments/gym_checkpoint_adapter.py L208-L221 Β· GymCheckpointAdapter.discover
-    async def discover(self) -> GymCheckpointParticipantSummary:
+    async def discover(self) -> tuple[str, ...]:
         ...
         participants = self._participants
-        return GymCheckpointParticipantSummary(
-            instance_id=self._instance.instance_id,
-            members=tuple(
-                (member.server_name, member.kind) for member in participants.members
-            ),
-        )
+        # The summary class goes (another review comment); return what setup needs.
+        return tuple([
+            member.server_name
+            for member in participants.members
+            if _is_restart_only(await self._status(member.server_name))
+        ])

@@ RL nemo_rl/algorithms/single_controller_utils/setup.py L801 Β· _spinup_gym
+    recovery = master_config.rollout_recovery
+    restart_only = shard_set.restart_only_servers  # from each replica's discover()
+    if recovery.turn_checkpointing_enabled:
+        if restart_only and not recovery.allow_restart_only_gym_servers:
+            raise ValueError(
+                "rollout_recovery.target_level='turn' needs every Gym agent and "
+                f"resources server to save its rollouts; these are restart-only: {restart_only}. "
+                "Add checkpoint support to them, or set "
+                "rollout_recovery.allow_restart_only_gym_servers=true to restart their "
+                "rollouts from the prompt after a crash."
+            )
+        if restart_only:
+            warnings.warn(
+                f"Gym servers {restart_only} are restart-only: after a crash, their "
+                "unfinished rollouts restart from the prompt. Rollouts on other servers "
+                "continue from their last saved turn.",
+                UserWarning,
+                stacklevel=2,
+            )
+        elif recovery.allow_restart_only_gym_servers:
+            warnings.warn(
+                "rollout_recovery.allow_restart_only_gym_servers=true, but every Gym "
+                "server can save its rollouts. Set it to false so a restart-only server "
+                "added later fails at setup.",
+                UserWarning,
+                stacklevel=2,
+            )
     return shard_set, time.perf_counter() - t0

_status(name) reads the same /ng-control/v1/checkpoint/status route Gym's own discover already calls, and _is_restart_only is true for {"restart_only": True} or {"mode": "restart_only"}. _initialize_checkpoint_adapters_concurrently already gets every replica's result from ray.get and drops it; it passes the names up instead.

RL: the snapshot accepts unsaved rollouts. The drain (L4622-4636) and the ownership check before the TQ save (L4666-4676) accept rollouts Gym reports as unsaved, instead of timing out or raising. Restore needs no change: it already restarts any rollout Gym did not restore from its prompt (rollout_recovery.py:527-548).

Not this. Gym's docs say the controller can resume, retire the blockers and prepare again (coordination.py:22-23). With a restart-only agent, every running rollout is a blocker, so each snapshot would cancel every rollout in flight. When the snapshot interval is shorter than a rollout, no rollout ever finishes.

So what. Today, with target_level: turn, any Gym environment other than simple_agent plus a session-saving resources server does not recover at turns. Each snapshot pauses /run dispatch for up to 300 s and then fails. After 3 failures in a row the run stops, and before the first step it stops at the first failure. Until the fix lands, use target_level: sibling for the whole run when any such environment is in it: per-environment overrides are not allowed with turn.