Skip to content

refactor(robot): unify the robot stack - one sim process shape, batched-first bridge/agent, grouped evals#481

Open
lukass16 wants to merge 36 commits into
mainfrom
lukass/phys-experimental
Open

refactor(robot): unify the robot stack - one sim process shape, batched-first bridge/agent, grouped evals#481
lukass16 wants to merge 36 commits into
mainfrom
lukass/phys-experimental

Conversation

@lukass16

@lukass16 lukass16 commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

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 generic Taskset.run API.

Solution

  • SimThread replaces the three SimRunner strategies: the sim always owns the main thread, serving runs on a background thread. The same mechanism covers CPU envs and Isaac.
  • RobotBridge is now batched-first; VecRobotBridge and IsaacBridge are removed. result() returns per-slot scores under "slots".
  • Recording moves to a shared hud.telemetry.robot module, replacing the three previous recorder classes. LeRobot dataset writing is split into agents/robot/dataset.py.
  • RobotAgent drives any number of env slots with a single loop; VecRobotAgent is removed.
  • Taskset.run(agent, runtime=..., num_envs=N) runs vectorized evals through a generic rollout_group; the robot-specific kwargs are removed and hud/eval no longer imports robot code.
  • New surfaces: env.gym(make_env) serves a gym-style sim declaratively (contract and capability derived from the factory), and hud.wrap(env) streams traces from a loop the user owns.

Outcome / Verification

Roughly 1,500 lines of duplicated code removed; the diff against main is 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 running Taskset.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 SimRunner strategies and in-process RobotEndpoint.serve.

Env ↔ agent contract changes: endpoint.reset() returns {prompt, token}; templates yield robot.token; agents connect with RobotClient.connect(..., token=) and fail fast without it. The bridge steps vectorized sims in lockstep with per-slot scalar wire obs/actions, barrier stepping, and result(token=) grading.

New integration paths: Environment.gym(make_env) spawns GymBridge via RobotEndpoint.spawn; wrap/TracedEnv streams 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.run enforces max_concurrent >= width for Shared. Control channel tracks per-session suspended tasks with parking, blind tasks.grade when one session is parked, and hello(session_id=) resume.

Agent side: RobotAgent loop inlined with adapt_chunk/adapt_action; recording splits into TraceRecorder (telemetry) and opt-in DatasetWriter (shared process dataset); BatchedModel defaults batch width to live concurrency. hud serve uses blocking serve_blocking.

Reviewed by Cursor Bugbot for commit 5ae0641. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread hud/environment/robot/introspect.py Outdated
Comment thread hud/agents/robot/agent.py Outdated
@mintlify

mintlify Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
hud 🟢 Ready View Preview Jul 6, 2026, 9:29 PM

lukass16 added 14 commits July 18, 2026 01:00
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.
jdchawla29 and others added 4 commits July 17, 2026 18:28
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>
lukass16 and others added 3 commits July 20, 2026 01:39
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>
lukass16 and others added 4 commits July 20, 2026 01:40
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>
lukass16 and others added 5 commits July 20, 2026 02:37
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>
Comment thread hud/__init__.py
Comment thread hud/agents/robot/agent.py
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d1d7e23. Configure here.

Comment thread hud/environment/robot/bridge.py
Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread hud/environment/robot/bridge.py
Comment thread hud/environment/robot/bridge.py
Comment thread hud/environment/robot/bridge.py Outdated
Comment thread hud/agents/robot/agent.py
lukass16 and others added 3 commits July 20, 2026 02:45
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>
Comment thread hud/agents/robot/agent.py
await client.close()
await robot.close()
run.trace.status = "completed"
run.trace.content = "done"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9884ccc. Configure here.

lukass16 and others added 2 commits July 20, 2026 02:50
…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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Fix All in Cursor

❌ 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5ae0641. Configure here.

Comment thread hud/agents/robot/agent.py
if terminated:
chunk.clear()

recorder.record_observation(obs["data"], tick=step)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Terminal observation never recorded

Medium Severity

The loop breaks on terminated before record_observation when step &gt; 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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5ae0641. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants