refactor(robot): unify the robot stack - one sim process shape, batched-first bridge/agent, grouped evals#481
refactor(robot): unify the robot stack - one sim process shape, batched-first bridge/agent, grouped evals#481lukass16 wants to merge 36 commits into
Conversation
|
Preview deployment for your docs. Learn more about Mintlify Previews.
|
The sim always owns the process main thread; serving (control channel + robot WebSocket) runs on a background loop thread, with every sim touch queued back to main. Cheap CPU envs block on the queue; Isaac/Omniverse gets its Kit pump from the idle hook - no per-sim threading choices. serve_blocking is the single entry hud serve and the module runner use, replacing the serve_pumped/asyncio.run branching.
…y.robot Recording is shared infrastructure (agent harness, hud.wrap, gym bridges), not agent code. TraceRecorder (one trace, span emission only - lifecycle stays with the caller) and JobRecorder (a vectorized env as one Job of per-episode traces) replace Recorder/VecRecorder/EpisodeRecorder and their dual-mode flags; video streaming moves alongside. The LeRobot dataset half of record.py becomes agents/robot/dataset.py (DatasetWriter). InferenceStep gains contract-derived per-dim action names so the viewer can label plots.
…mBridge
One bridge serves num_envs slots in lockstep; a plain single env is a
batch of one speaking the scalar wire framing on the same code path, so
VecRobotBridge and IsaacBridge collapse into the base. GymBridge is the
generic bridge over any gym-style factory (arg partitioning by factory
signature, rebuild-on-instance-change, success probing, torch/int action
handling). Grading becomes result() -> {score, success, slots: [...]}
(one dict per slot), replacing the result_batch plumbing through the
endpoint's JSON-RPC. SimRunner strategies are gone - bridges route every
sim touch through the shared SimThread - and the endpoint gains
serve_blocking() for split-process sims (replacing serve_forever).
env.gym(make_env) turns any gym-style factory into a served robot sim: contract derived from a sample observation (round-tripped through an editable contract.json), capability minted, lifecycle wired to the env's hooks, episodes driven via the returned handle (sim.reset / sim.result). A factory accepting num_envs is the vectorization declaration. hud.wrap is the loop-owning counterpart: wrap an env you drive yourself and every episode streams to the platform as a trace under one job (lazy top-level attr so core hud stays free of the robot extra).
One open-loop chunk queue drives single and vectorized envs alike: the wire framing (scalar terminated vs [N] mask) sets the batch size, spent slots refill from one batched forward, adapt_chunk converts per slot at inference time. __call__(run) is the generic rollout contract (a group of one, still coalescing through ainfer so BatchedAgent works unchanged); drive(runs, client) is the grouped-eval entry recording spans per slot. VecRobotAgent and VecLeRobotAdapter are deleted - LeRobotAdapter maps batched and unbatched observations with the same wiring.
…vs=) One vectorized env instance becomes num_envs graded runs sharing a group_id, behind the same Taskset.run surface and with no robot concepts in hud/eval: the atom goes through the normal env client and template (num_envs injected into task args; an env that doesn't accept it fails loudly), the contract rides the capability manifest, the agent's drive(runs, client) entry drives the slots, and run i grades from the template result's slots[i]. Replaces vec_rollout and the vectorized=/ contract= kwargs; num_envs composes with group= (k instances x N traces, group ids per instance) and requires a self-managed runtime.
env.gym-first environment side, the one sim-serving process shape (SimRunner section replaced), slots-based grading, serve_blocking for split-process sims, a new vectorized-envs-and-grouped-evals section, and a refreshed API summary.
batch_size is now an optional cap: with no value the worker stacks every ainfer call queued within the max_wait_s window, so the forward is as wide as whatever is in flight and max_concurrent never needs manual pairing. A set cap still flushes the window early once reached.
"group" already means statistical repeats (group=k); the vectorized atom deserves its own word. Docstrings and error messages now say "vectorized" throughout and the atom's docstring is tightened. Also narrows the minted trace id before trace_enter (pyright).
hud/agents is strict-typed in CI: restore the stubless-torch Any alias in LeRobotAdapter, type the chunk queues as deque[ActionArray], narrow the optional model to a local before the loop, and suppress the deliberate private _client check when picking recorder mode.
Cameras in derived gym contracts are tagged type: rgb (no dtype: image), so frames mapped to non-video LeRobot columns. Image detection now accepts both conventions; missing dtypes default to float32 and missing shapes are filled from the first real frame before the dataset is created. Also imports importlib.util explicitly (pyright).
Finished slots keep acting so the [N, A] action frame stays complete, but their chunk refills no longer emit InferenceSteps - matching the existing observation guard, so a finished slot's trace gains no orphan inference.
Adds a Taskset.run parameter reference (runtime / group / num_envs / max_concurrent / rollout_timeout / job) under the vectorized-evals section, updates the batching example for the self-sizing BatchedModel, and aligns wording with the vec_rollout rename.
7bd800c to
7710838
Compare
Key the control channel's suspended tasks by session id: each connection starts and grades its own task instead of the last start cancelling whatever any other connection had in flight. A connection drop parks the session's task; a later connection grades it by resuming the session (hello with its session_id) or, when exactly one session is parked, by a plain tasks.grade — so the split 'hud task start' / 'hud task grade' flow keeps working unchanged, and the ambiguous case errors loudly instead of guessing.
max_steps was configurable per-subclass but every override just repeated the same default; keep one constant (520) via the call-site argument instead of a class attribute agents had to remember to set. Co-authored-by: Cursor <cursoragent@cursor.com>
…ative gym targets The env server no longer forks itself around a sim; every sim runs as a separate process, always the same way. env.gym(...) spawns it (or a custom sim program calls serve_bridge directly), and the env server's serving path (server.serve_blocking) is a plain asyncio.run — no more sim-main special case. - bridge.py absorbs the sim program (formerly serve.py): serve_bridge, gym_command, and the CLI (`python -m hud.environment.robot.bridge`) now live next to RobotBridge/GymBridge, since both ends of the argv format belong together. - GymBridge / env.gym(target) take three declarative target forms: a module-level factory (unchanged), a gymnasium registry id, or a constructed registry env — reduced by gym_command to its EnvSpec JSON and closed, since only the declaration can cross the fork; the sim process rebuilds it via gym.make/gym.make_vec. - sim_thread.py -> sim.py: the sim-process shape (SimThread/run_with_sim) is unconditional infrastructure every sim uses, not a special case to fork around, so it's named and documented as the one shape, not an alternative. - introspect.py + wrap.py merge into gym.py: one module for the gym integration both GymBridge and hud.wrap share (observation splitting, contract derivation, TracedEnv). - endpoint.py (RobotEndpoint) and env.py (Environment.gym) updated for the merged module layout and broadened target type. Co-authored-by: Cursor <cursoragent@cursor.com>
Generic hook for per-episode data an env hands back on start (e.g. a robot bridge slot token), without hud/eval learning robot concepts. Co-authored-by: Cursor <cursoragent@cursor.com>
N sessions now share one control-channel TCP link, so _call needs a
lock around send+read to keep replies from crossing. reset() returns
the full {prompt, token} reply and result(token=...) passes it through,
matching the bridge's per-slot claim/release surface.
Co-authored-by: Cursor <cursoragent@cursor.com>
Replaces the single-agent, batched-wire bridge with N claimable slots
behind one scalar openpi WebSocket per connection:
- _SlotRegistry: reset-on-first-claim (global reset + claim slot 0 when
all free), claim a free slot otherwise, error when none are free.
A released slot stays unreclaimable until the next global reset
(v1 keeps whole-batch episodes).
- Each connection's first frame is {"claim": token}; the bridge binds
it to that slot and fans out that slot's scalar observation only.
- _tick_loop barriers claimed, connected slots: gather pending actions
(or step_timeout -> hold for stragglers), step once as [N, A], fan
scalar frames back out. One slow agent can no longer stall the batch.
- Control surface: reset() claims + returns {prompt, token}; result()
reads one slot's grade by token and frees it. The old aggregate
"slots" grade list is gone.
GymBridge's build-arg partition now also takes env.gym(..., num_envs=8)
style defaults, so num_envs is sim build config, not an eval parameter.
Co-authored-by: Cursor <cursoragent@cursor.com>
After the connect-time metadata frame, send {"claim": token} when the
caller supplies one, binding this WebSocket to the bridge's matching
slot. terminated is now always scalar (each connection is one slot).
Co-authored-by: Cursor <cursoragent@cursor.com>
RobotAgent goes back to the main-branch contract: __call__(run) only. Deletes drive(runs, client), the single-vs-batched wire-shape sniffing, the [None] lifts, per-slot recorder fan-out, and the n==1 vs asyncio.to_thread(model.infer) fork - ainfer is the only inference path now, so BatchedModel is the sole batching seam on the agent side. The loop claims its slot token from run.started["robot"]["token"] and connects one scalar RobotClient, same as any single-env run. Co-authored-by: Cursor <cursoragent@cursor.com>
hud/eval loses the vectorized-specific surface: vec_rollout, its "slots"-grade contract, and Taskset.run(num_envs=) all go, leaving group as the only rollout multiplier and rollout() as the only execution atom - both closer to main than before. Shared(provider, width=N) replaces it: a refcounting Provider wrapper that provisions the substrate on the first acquire and forwards that same address to later acquires, tearing down on the last release. Pairs with group=width, max_concurrent=width so N ordinary rollouts land as N sessions on one shared substrate (e.g. a vectorized robot sim) instead of each getting a fresh one. Taskset.run validates that pairing eagerly. Co-authored-by: Cursor <cursoragent@cursor.com>
Rewrites the "Vectorized envs and evals" section around the new
mechanism: num_envs as sim build config, the agent claiming its slot
token from run.started, and group + Shared(provider, width=N) as the
run-side knobs - instead of the deleted num_envs= / "slots" contract.
Adds a Shared reference entry to runtime.mdx (table row + constructor
section) so the robot docs can link instead of repeat, and updates the
endpoint reset/result examples in robots.mdx and the robot-benchmark
cookbook to the {prompt, token} shape.
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Buffer per-rollout frames and commit whole episodes into a process-shared dataset so BatchedAgent clones write one dataset instead of per-trace shards. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…ge to gym Dissolve the separate SimThread/run_with_sim surface into RobotBridge._run_on_sim and serve_bridge, and relocate GymBridge/gym_command/main next to the gym introspection helpers so each module has one job. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
| writer = DatasetWriter(robot.contract, fps=fps) | ||
|
|
||
| print(f"[agent] episode started: {prompt!r}", flush=True) | ||
| await self._loop(robot, obs, prompt, recorder, writer, max_steps=max_steps) |
There was a problem hiding this comment.
Class max_steps no longer honored
High Severity
RobotAgent.__call__ always uses the max_steps=520 parameter default and no longer reads a subclass max_steps attribute. Taskset.run / rollout call agent(run) with no kwargs, so agents that previously set max_steps on the class silently run with 520 instead.
Reviewed by Cursor Bugbot for commit d1d7e23. Configure here.
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…ected ones A slot claimed via the control plane but whose agent is still dialing no longer gets stepped with hold actions; the tick barrier waits for it (up to step_timeout) so episodes cannot advance before the first observation. Co-authored-by: Cursor <cursoragent@cursor.com>
…lt 520 Co-authored-by: Cursor <cursoragent@cursor.com>
| await client.close() | ||
| await robot.close() | ||
| run.trace.status = "completed" | ||
| run.trace.content = "done" |
There was a problem hiding this comment.
Recorder cleanup skipped on errors
Medium Severity
TraceRecorder.close() and DatasetWriter.end_episode() run only on the success path after _loop. If the rollout raises, video tails are never flushed and a buffered LeRobot episode is never committed, even though robot.close() still runs in finally.
Reviewed by Cursor Bugbot for commit 9884ccc. Configure here.
| # Further --key value pairs from gym_command become build defaults. | ||
| for flag, value in zip(unknown, unknown[1:]): | ||
| if flag.startswith("--"): | ||
| defaults[flag[2:].replace("-", "_")] = value |
There was a problem hiding this comment.
Gym kwargs lose native types
Medium Severity
gym_command stringifies every build default onto the argv, and main reloads unknown --key value pairs as plain strings. Boolean defaults such as False become the truthy string "False", and ints/floats are no longer numeric when the factory runs in the child process.
Reviewed by Cursor Bugbot for commit 9884ccc. Configure here.
…ocking The agent raises on a run without a robot token, and the bridge times out a connection that never sends its claim frame (both ends previously waited on each other forever). Co-authored-by: Cursor <cursoragent@cursor.com>
…e and per-slot default grades Three vectorized-bridge fixes: a later claim with different task kwargs now raises instead of silently running the first claim's task (lockstep sims share one batch reset); hold_action derives the action dim from contract names when a derived contract omits shape; the default result_slots grades every slot so releasing an index > 0 no longer raises. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
There are 5 total unresolved issues (including 3 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 5ae0641. Configure here.
| if slot is not None and slot.ws is ws: | ||
| slot.ws = None | ||
| slot.action = None | ||
| self._action_event.set() # wake the barrier so it doesn't wait on us |
There was a problem hiding this comment.
Disconnect stalls vectorized barrier
High Severity
When a claimed slot's WebSocket drops, the handler clears ws and action but never marks the slot idle. The tick barrier still treats that slot as pending, so remaining live rollouts wait up to step_timeout (30s) before holding. In vectorized evals, any early-finished or disconnecting slot stalls every later lockstep step.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 5ae0641. Configure here.
| if terminated: | ||
| chunk.clear() | ||
|
|
||
| recorder.record_observation(obs["data"], tick=step) |
There was a problem hiding this comment.
Terminal observation never recorded
Medium Severity
The loop breaks on terminated before record_observation when step > 0. The post-action terminal frame is fetched at the end of the previous iteration and then dropped, so traces and datasets miss the final observation the old harness recorded before stopping.
Reviewed by Cursor Bugbot for commit 5ae0641. Configure here.


Issue
The robot framework maintained two parallel implementations - single-env and vectorized - with duplicated agent loops, recorders, bridges, and eval paths. Sim execution had three separate threading mechanisms, and the vectorized eval path leaked robot-specific arguments (
contract=,vectorized=) into the genericTaskset.runAPI.Solution
SimThreadreplaces the threeSimRunnerstrategies: the sim always owns the main thread, serving runs on a background thread. The same mechanism covers CPU envs and Isaac.RobotBridgeis now batched-first;VecRobotBridgeandIsaacBridgeare removed.result()returns per-slot scores under"slots".hud.telemetry.robotmodule, replacing the three previous recorder classes. LeRobot dataset writing is split intoagents/robot/dataset.py.RobotAgentdrives any number of env slots with a single loop;VecRobotAgentis removed.Taskset.run(agent, runtime=..., num_envs=N)runs vectorized evals through a genericrollout_group; the robot-specific kwargs are removed andhud/evalno longer imports robot code.env.gym(make_env)serves a gym-style sim declaratively (contract and capability derived from the factory), andhud.wrap(env)streams traces from a loop the user owns.Outcome / Verification
Roughly 1,500 lines of duplicated code removed; the diff against
mainis net negative despite the new features. Test suite and lint pass. Verified end-to-end by serving a vectorized stub env in a child process and runningTaskset.run(num_envs=3)to three graded traces. Downstream consumers (assembly_bench,bench,rl) updated.Note
High Risk
Large protocol and lifecycle changes (slot tokens, sim subprocess shape, multi-session grading) across robot eval, bridges, and runtimes; misconfiguration can deadlock agents or grade wrong sessions.
Overview
Unifies the robot stack so sims always run in a dedicated process (main thread owns physics; WebSocket + JSON-RPC control on a background loop), replacing configurable
SimRunnerstrategies and in-processRobotEndpoint.serve.Env ↔ agent contract changes:
endpoint.reset()returns{prompt, token}; templates yieldrobot.token; agents connect withRobotClient.connect(..., token=)and fail fast without it. The bridge steps vectorized sims in lockstep with per-slot scalar wire obs/actions, barrier stepping, andresult(token=)grading.New integration paths:
Environment.gym(make_env)spawnsGymBridgeviaRobotEndpoint.spawn;wrap/TracedEnvstreams traces for user-owned loops. Capability contracts are fetched from the bridge (endpoint.capability()without passing contract in).Eval & control plane:
Shared(provider, width=N)refcount-shares one substrate for concurrent rollouts;Taskset.runenforcesmax_concurrent >= widthfor Shared. Control channel tracks per-session suspended tasks with parking, blindtasks.gradewhen one session is parked, andhello(session_id=)resume.Agent side:
RobotAgentloop inlined withadapt_chunk/adapt_action; recording splits intoTraceRecorder(telemetry) and opt-inDatasetWriter(shared process dataset);BatchedModeldefaults batch width to live concurrency.hud serveuses blockingserve_blocking.Reviewed by Cursor Bugbot for commit 5ae0641. Bugbot is set up for automated code reviews on this repo. Configure here.