openral-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2664 @@
1
+ """``openral deploy sim`` — boot the full ROS graph against a digital-twin HAL.
2
+
3
+ Sibling of ``openral deploy run``: where ``deploy run`` drives a tight Python
4
+ tick loop against a HAL + ``SafetyClient``, ``deploy sim`` shells
5
+ ``ros2 launch openral_rskill_ros sim_e2e.launch.py`` so the operator gets
6
+ dashboard + C++ safety kernel + reasoner + prompt router + runtime
7
+ (world_state + skill_runner) + HAL in one command, running against the
8
+ HAL's digital-twin (MuJoCo viewer) mode.
9
+
10
+ The launch graph is robot-agnostic — one ``sim_e2e.launch.py`` for every
11
+ robot. The CLI's job is to resolve everything robot-specific:
12
+
13
+ * The robot manifest at ``robots/<robot_id>/robot.yaml``.
14
+ * The HAL package + executable + node name + per-robot default
15
+ parameter dict, looked up by ``robot_id`` in ``_ROBOT_HAL_REGISTRY``.
16
+ The lookup asserts the HAL's declared ``supported_robot_names`` matches
17
+ the manifest's ``name`` field — a mismatch (someone wires
18
+ ``openarm`` to the so100 HAL by accident) fails loud.
19
+
20
+ No envelope YAML file is involved on either side:
21
+
22
+ * The robot manifest is the single source of truth for the safety
23
+ kernel envelope. ``sim_e2e.launch.py``'s ``compose_runtime_graph``
24
+ callback loads ``robot.yaml`` via Pydantic at launch time, calls
25
+ ``openral_safety.envelope_loader.compute_intersection(robot, skill=None)``
26
+ + ``kernel_params_from_envelope(...)``, and forwards each field of
27
+ the resulting :class:`EnvelopeIntersection` as a ROS parameter on
28
+ the kernel node (see ``cpp/openral_safety_kernel/src/envelope.cpp``
29
+ — `n_dof`, `joint_position_min/max`, `joint_velocity_max`,
30
+ `joint_torque_max`, scalar caps, deadman flag). The legacy
31
+ ``envelope_file:=PATH`` path was removed.
32
+
33
+ The reasoner is NOT preselected: it walks the in-tree ``rskills/`` and
34
+ filters by the robot's capabilities at on_configure. ``openral deploy sim``
35
+ intentionally does not accept ``--rskill`` because the reasoner picks
36
+ the active rSkill dynamically and switching skills is its job, not the
37
+ operator's bring-up command.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import contextlib
43
+ import importlib
44
+ import json
45
+ import os
46
+ import shlex
47
+ import shutil
48
+ import signal
49
+ import subprocess
50
+ import sys
51
+ import sysconfig
52
+ import tempfile
53
+ from collections.abc import Callable, Iterable
54
+ from dataclasses import dataclass, field
55
+ from pathlib import Path
56
+ from typing import TYPE_CHECKING
57
+
58
+ import typer
59
+ import yaml
60
+ from openral_core.exceptions import ROSCapabilityMismatch, ROSConfigError
61
+ from rich.console import Console
62
+
63
+ if TYPE_CHECKING:
64
+ from openral_core import RobotDescription, RSkillManifest
65
+
66
+ __all__ = [
67
+ "LaunchInvocation",
68
+ "assert_ros2_packages_discoverable",
69
+ "deploy_sim_command",
70
+ "resolve_launch_invocation",
71
+ ]
72
+
73
+ _console = Console(soft_wrap=True)
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class _HalSpec:
78
+ """Per-robot HAL spawn descriptor.
79
+
80
+ ``package`` / ``executable`` / ``node_name`` parameterise the HAL
81
+ ``LifecycleNode`` in ``sim_e2e.launch.py``. ``supported_robot_names``
82
+ is the set of ``RobotDescription.name`` values this HAL is willing
83
+ to drive — the CLI asserts the loaded manifest's name is in this
84
+ set so a mis-paired registry entry (openarm HAL routed at an so100
85
+ manifest) fails loud at resolution time, not at the HAL's first
86
+ actuation tick. ``default_params`` is the parameter dict the HAL
87
+ accepts via its ROS parameter interface; operator-supplied
88
+ ``--hal`` overrides win.
89
+ """
90
+
91
+ package: str
92
+ executable: str
93
+ node_name: str
94
+ supported_robot_names: frozenset[str]
95
+ default_params: dict[str, object] = field(default_factory=dict)
96
+ # HAL nodes that declare a `sim_env_yaml` ROS
97
+ # parameter (today: openral_hal_panda_mobile) opt in via this
98
+ # flag. When True, `openral deploy sim --config <yaml>` injects the
99
+ # resolved config path into hal_params so the HAL builds a live
100
+ # `openral_sim.SimRollout` env in-process. Other HALs leave it
101
+ # False so rclpy doesn't reject the unknown parameter at startup
102
+ # (`automatically_declare_parameters_from_overrides=False` is the
103
+ # default).
104
+ supports_sim_env_yaml: bool = False
105
+ # HAL nodes built via `make_lifecycle_main_from_manifest`
106
+ # (franka / ur5e / ur10e / aloha / g1 / h1 / rizon4 / so100 / so101) declare
107
+ # `robot_yaml` + `hal_mode` params and construct their HAL through
108
+ # `build_hal(mode=...)`. When True, `openral deploy sim` injects the resolved
109
+ # manifest path + `hal_mode="sim"`; `openral deploy run` injects
110
+ # `hal_mode="real"`.
111
+ manifest_driven: bool = False
112
+ # issue #191 Phase 2 — a manifest-driven arm that builds a *bare* MuJoCo
113
+ # twin (`MujocoArmHAL.from_description`) in sim rather than scene-attaching:
114
+ # A manifest-driven arm that builds its OWN sim MJCF rather than
115
+ # scene-attaching: so100 / so101 derive a bare `MujocoArmHAL` twin from the
116
+ # manifest's `sim:` block; openarm composes a tabletop MJCF from
117
+ # `scene_defaults.composition` (issue #191 Phase 3b). When True, the
118
+ # manifest-driven injection below skips `sim_env_yaml` so the node builds the
119
+ # explicit `hal.sim` HAL (with the composed mjcf threaded in) instead of a
120
+ # scene-attached `SimAttachedHAL`. Other manifest arms leave it False and
121
+ # scene-attach.
122
+ bare_twin_sim: bool = False
123
+
124
+
125
+ _ROBOT_HAL_REGISTRY: dict[str, _HalSpec] = {
126
+ # Keys match the directory name under ``robots/<robot_id>/``; the
127
+ # CLI looks up ``robots/<robot_id>/robot.yaml`` against this key. To
128
+ # add a new robot to ``openral deploy sim``: ship a ``openral_hal_<X>``
129
+ # ROS package with a ``lifecycle_node.py`` executable, register it
130
+ # in the ros2-build Justfile target, and add an entry here. The
131
+ # Python HAL adapters under ``python/hal/src/openral_hal/`` are a
132
+ # different layer (used by ``openral deploy run`` / ``openral sim run``);
133
+ # they do not provide ROS lifecycle nodes by themselves.
134
+ "openarm": _HalSpec(
135
+ package="openral_hal_openarm",
136
+ executable="lifecycle_node.py",
137
+ node_name="openral_hal_openarm",
138
+ supported_robot_names=frozenset({"openarm_v2", "openarm"}),
139
+ # issue #191 Phase 3b — migrated onto the manifest-driven node. Scene
140
+ # composition (robot_lift_z / robot_forward_x / white_background) moved to
141
+ # the manifest's `scene_defaults.composition`; HAL kwargs (settle_steps /
142
+ # gravity_enabled / staleness_limit_s) to `hal.parameters`; cameras via
143
+ # SimSensorBridge + OpenArmMujocoHAL.read_images. Only the viewer toggle
144
+ # remains a node ROS param.
145
+ default_params={
146
+ "viewer_enabled": True,
147
+ },
148
+ manifest_driven=True,
149
+ # openarm builds its own MJCF via scene COMPOSITION (the manifest's
150
+ # `scene_defaults.composition`), not scene-attach — so suppress the
151
+ # `sim_env_yaml` injection (see `bare_twin_sim`).
152
+ bare_twin_sim=True,
153
+ ),
154
+ "so100_follower": _HalSpec(
155
+ # issue #191 Phase 2 — migrated onto the manifest-driven node
156
+ # (`make_lifecycle_main_from_manifest`). `hal_mode="sim"` derives a bare
157
+ # `MujocoArmHAL` twin; `hal_mode="real"` builds `SO100FollowerHAL` with
158
+ # `port` / `calibrate_on_connect` from the manifest's `hal.parameters`
159
+ # (so no `port` ROS param — the node would reject the unknown override).
160
+ package="openral_hal_so100",
161
+ executable="lifecycle_node.py",
162
+ node_name="openral_hal_so100",
163
+ supported_robot_names=frozenset({"so100_follower"}),
164
+ manifest_driven=True,
165
+ bare_twin_sim=True,
166
+ ),
167
+ "so101_follower": _HalSpec(
168
+ # The SO-101 is a hardware revision of the SO-100: identical 6-DoF
169
+ # kinematic chain driven by the same lerobot Feetech STS3215 serial
170
+ # backend, so it reuses the ``openral_hal_so100`` ROS lifecycle node
171
+ # (now manifest-driven) verbatim. ``openral deploy sim`` injects this
172
+ # robot's manifest + ``hal_mode="sim"`` and the node builds a bare
173
+ # ``MujocoArmHAL.from_description`` from ``robots/so101_follower/
174
+ # robot.yaml`` (``assets.mjcf`` → ``so101_new_calib``). The SAME node
175
+ # serves so100 (``so_arm100``) and so101 (``so101_new_calib``) from
176
+ # their own MJCF, so no dedicated ``openral_hal_so101`` package exists
177
+ # or is needed (CLAUDE.md §1.13). The robot-name guard below keeps this
178
+ # entry bound to the so101 manifest.
179
+ package="openral_hal_so100",
180
+ executable="lifecycle_node.py",
181
+ node_name="openral_hal_so100",
182
+ supported_robot_names=frozenset({"so101_follower"}),
183
+ manifest_driven=True,
184
+ bare_twin_sim=True,
185
+ ),
186
+ "panda_mobile": _HalSpec(
187
+ # panda_mobile publishes /joint_states + /odom +
188
+ # /scan and broadcasts the odom -> base_link TF that slam_toolbox + Nav2
189
+ # both need. issue #191 Phase 3 migrated it onto the manifest-driven node:
190
+ # MobileBaseBridge owns /odom + TF + /cmd_vel (gated on the manifest's
191
+ # `base_joints`), SimSensorBridge owns /scan + cameras + depth + viewer.
192
+ package="openral_hal_panda_mobile",
193
+ executable="lifecycle_node.py",
194
+ node_name="openral_hal_panda_mobile",
195
+ supported_robot_names=frozenset({"panda_mobile"}),
196
+ default_params={
197
+ "odom_publish_rate_hz": 20.0,
198
+ # The /scan envelope (rate / beam count / range) is NOT hardcoded
199
+ # here: it's derived from the robot.yaml lidar_2d sensor at resolve
200
+ # time (see _resolve_deploy_target) so robot.yaml stays the single
201
+ # source of truth. viewer_enabled opens a non-blocking
202
+ # mujoco.viewer.launch_passive window (no-op headless).
203
+ "viewer_enabled": True,
204
+ },
205
+ # Scene-attach (SimAttachedHAL via sim_env_yaml) when a scene config is
206
+ # given; bare PandaMobileHAL digital twin otherwise.
207
+ manifest_driven=True,
208
+ supports_sim_env_yaml=True,
209
+ ),
210
+ "franka_panda": _HalSpec(
211
+ package="openral_hal_franka",
212
+ executable="lifecycle_node.py",
213
+ node_name="openral_hal_franka",
214
+ supported_robot_names=frozenset({"franka_panda"}),
215
+ default_params={},
216
+ manifest_driven=True,
217
+ ),
218
+ "ur5e": _HalSpec(
219
+ package="openral_hal_ur5e",
220
+ executable="lifecycle_node.py",
221
+ node_name="openral_hal_ur5e",
222
+ supported_robot_names=frozenset({"ur5e"}),
223
+ default_params={},
224
+ manifest_driven=True,
225
+ ),
226
+ "ur10e": _HalSpec(
227
+ package="openral_hal_ur10e",
228
+ executable="lifecycle_node.py",
229
+ node_name="openral_hal_ur10e",
230
+ supported_robot_names=frozenset({"ur10e"}),
231
+ default_params={},
232
+ manifest_driven=True,
233
+ ),
234
+ "aloha_bimanual": _HalSpec(
235
+ package="openral_hal_aloha",
236
+ executable="lifecycle_node.py",
237
+ node_name="openral_hal_aloha",
238
+ supported_robot_names=frozenset({"aloha_bimanual"}),
239
+ default_params={},
240
+ manifest_driven=True,
241
+ ),
242
+ "aloha_agilex": _HalSpec(
243
+ # RoboTwin owns the SAPIEN robot; deploy-sim only needs a ROS lifecycle
244
+ # host for SimAttachedHAL. This package is intentionally sim-only and
245
+ # generic, so it never claims a real AgileX hardware transport.
246
+ package="openral_hal_scene_attached",
247
+ executable="lifecycle_node.py",
248
+ node_name="openral_hal_scene_attached",
249
+ supported_robot_names=frozenset({"aloha_agilex"}),
250
+ default_params={},
251
+ manifest_driven=True,
252
+ supports_sim_env_yaml=True,
253
+ ),
254
+ "widowx": _HalSpec(
255
+ # SimplerEnv owns the SAPIEN WidowX twin; OpenRAL has no real WidowX HAL.
256
+ # The generic scene-attached node is valid for deploy-sim only because
257
+ # build_hal(mode="sim", sim_env_yaml=...) bypasses hal.sim entirely.
258
+ package="openral_hal_scene_attached",
259
+ executable="lifecycle_node.py",
260
+ node_name="openral_hal_scene_attached",
261
+ supported_robot_names=frozenset({"widowx"}),
262
+ default_params={},
263
+ manifest_driven=True,
264
+ supports_sim_env_yaml=True,
265
+ ),
266
+ "g1": _HalSpec(
267
+ package="openral_hal_g1",
268
+ executable="lifecycle_node.py",
269
+ node_name="openral_hal_g1",
270
+ supported_robot_names=frozenset({"g1"}),
271
+ default_params={},
272
+ manifest_driven=True,
273
+ ),
274
+ "h1": _HalSpec(
275
+ package="openral_hal_h1",
276
+ executable="lifecycle_node.py",
277
+ node_name="openral_hal_h1",
278
+ supported_robot_names=frozenset({"h1"}),
279
+ default_params={},
280
+ manifest_driven=True,
281
+ ),
282
+ "rizon4": _HalSpec(
283
+ package="openral_hal_rizon4",
284
+ executable="lifecycle_node.py",
285
+ node_name="openral_hal_rizon4",
286
+ supported_robot_names=frozenset({"rizon4"}),
287
+ default_params={},
288
+ manifest_driven=True,
289
+ ),
290
+ }
291
+
292
+
293
+ @dataclass(frozen=True)
294
+ class LaunchInvocation:
295
+ """Resolved ``ros2 launch`` argv + the metadata that built it.
296
+
297
+ Returned by :func:`resolve_launch_invocation` so the dispatcher can
298
+ pretty-print under ``--dry-run`` and the unit tests can assert on
299
+ the resolved fields without touching ``subprocess``.
300
+ """
301
+
302
+ robot_id: str
303
+ robot_yaml: Path
304
+ robot_manifest_name: str
305
+ hal: _HalSpec
306
+ hal_params: dict[str, object]
307
+ hal_mode: str
308
+ """``"sim"`` (``openral deploy sim``) or ``"real"``
309
+ (``openral deploy run``). Forwarded into the launch as ``hal_mode:=…`` so
310
+ the reasoner's action-mode palette gate matches the HAL this graph
311
+ brings up (sim admits cartesian/OSC skills the scene's robosuite OSC
312
+ controller can execute; real admits only the robot's declared
313
+ ``supported_control_modes``)."""
314
+ reset_to_pose_service: str
315
+ approach_skill_id: str
316
+ """MoveIt approach rSkill URI (e.g. ``rskills/rskill-moveit-joints``)
317
+ forwarded into the launch as ``approach_skill_id:=…`` so the skill_runner
318
+ plans a collision-free MoveGroup motion to the next skill's ``starting_pose``
319
+ instead of the teleport snap. Empty (the default) keeps the legacy
320
+ best-effort ``ResetToPose`` snap — opt in with ``--approach-skill-id`` once a
321
+ ``move_group`` is in the graph."""
322
+ enable_slam: bool
323
+ """Opt-in. Set by ``openral deploy sim --enable-slam``;
324
+ forwarded into the launch as ``enable_slam:=true``."""
325
+ slam_backend: str
326
+ """Which SLAM backend the launch composes when
327
+ ``enable_slam`` is true: ``"lidar"`` (slam_toolbox, needs ``/scan``),
328
+ ``"visual"`` (cuVSLAM + nvblox, camera-based, for lidar-less robots),
329
+ or ``"none"`` (no SLAM). Resolved from capabilities — ``has_lidar``
330
+ selects ``lidar`` (it wins when both flags are set, needing no AI depth
331
+ model); else ``has_vision_slam`` selects ``visual``. Forwarded as
332
+ ``slam_backend:=…``."""
333
+ enable_nav2: bool
334
+ """Opt-in for the Nav2 navigation stack. Set by
335
+ ``openral deploy sim --enable-nav2``; forwarded into the launch as
336
+ ``enable_nav2:=true``. Defaults to ``has_lidar`` — every robot
337
+ that runs slam_toolbox needs a planner to consume the resulting
338
+ map, so the two are auto-co-enabled."""
339
+ enable_octomap: bool
340
+ """Opt-in for the world-collision perception leg
341
+ (octomap_server + openral_octomap_bridge + the kernel's
342
+ capsule-vs-voxel check). Set by ``openral deploy sim --enable-octomap``;
343
+ forwarded as ``enable_octomap:=true``. Defaults to "auto" = the robot
344
+ manifest declares a depth SensorSpec (nothing to map otherwise)."""
345
+ clock_origin: str
346
+ """ClockAuthority origin forwarded as ``clock_origin:=…``. Derived from
347
+ the deployment: simulator-owned elapsed time for sim backends that expose
348
+ ``sim_time_ns``; host wall time for real deployments or clock-less scenes.
349
+ Operators do not choose ROS ``use_sim_time`` directly."""
350
+ enable_object_detector: bool
351
+ """Object-detection perception leg
352
+ (ros_image_detector_node → /openral/perception/objects → world-state
353
+ object-lift → /openral/world_voxels). **On by default**; disabled with
354
+ ``openral deploy sim --no-object-detector``. Forwarded as
355
+ ``enable_object_detector:=true|false``. Auto-downgrades to ``false`` when no
356
+ backend is available (omdet deps absent *and* the RT-DETR ONNX missing)."""
357
+ object_detector_onnx: Path
358
+ """Absolute path to the RT-DETR ONNX weights used by the legacy /
359
+ fallback detector path. Forwarded as ``object_detector_onnx:=<path>``.
360
+ Defaults to the in-tree ``rskills/rtdetr-coco-r18/model.onnx``; passing it
361
+ explicitly selects the fixed-label RT-DETR path over the omdet default."""
362
+ object_detector_manifest: str
363
+ """Path to a kind:detector rSkill manifest. When set,
364
+ the detector node builds its backend from the manifest (runtime:pytorch →
365
+ the open-vocab LocateAnything VLM sidecar; runtime:onnx → RT-DETR ONNX).
366
+ Forwarded as ``object_detector_manifest:=<path>``. Empty = the RT-DETR ONNX
367
+ fallback. By default (no explicit override) this resolves to the
368
+ ``omdet-turbo-indoor`` manifest when the omdet deps are importable."""
369
+ object_detector_query: str
370
+ """Initial open-vocabulary query for a VLM detector
371
+ (e.g. 'red mug'). Forwarded as ``object_detector_query:=<text>``. Empty =
372
+ the manifest's ``detector.labels`` default. Ignored by ONNX detectors."""
373
+ object_detector_locators: tuple[str, ...]
374
+ """Resolved manifest paths of the ``mode: on_demand`` open-vocab
375
+ locators to bring up alongside the continuous detector. The launch builds one
376
+ namespaced lifecycle node per entry (``/openral/perception/<alias>/locate_in_view``)
377
+ so the reasoner can choose a model via ``LocateInViewTool.detector``. Forwarded
378
+ as ``object_detector_locators:=<comma-joined paths>`` only when non-empty.
379
+ Defaults to the omdet-turbo-locator manifest when the detector is on and the
380
+ omdet deps are importable (LocateAnything is opt-in via an explicit path)."""
381
+ spatial_memory_ingest: bool
382
+ """Opt-in. Set by ``openral deploy sim --spatial-memory-ingest``;
383
+ forwarded as ``spatial_memory_ingest:=true``. The reasoner then accumulates
384
+ a durable SpatialMemory from the object-lift producer's
385
+ ``WorldState.detected_objects`` so ``recall_object`` recalls what the robot
386
+ has seen. Defaults to "auto" = enabled when the object detector is."""
387
+ enable_foxglove: bool
388
+ """Opt-in. Off by default. Set by ``openral deploy sim --foxglove``;
389
+ forwarded as ``enable_foxglove:=true``. Spawns the read-only
390
+ ``foxglove_bridge`` as part of the deploy-sim runtime graph so operators
391
+ can view the live scene (cameras, /tf, joint states, nav map) in
392
+ Foxglove Studio without an extra bring-up step. Cannot actuate the robot
393
+ (view-only; ``clientPublish``/``services`` capabilities omitted)."""
394
+ foxglove_port: int
395
+ """Foxglove WebSocket port. Forwarded as ``foxglove_port:=…``.
396
+ Default 8765 (the ``foxglove_bridge`` upstream default)."""
397
+ initial_task_prompt: str
398
+ """Operator goal delivered to the reasoner at startup (cli priority).
399
+
400
+ Sourced only from ``--initial-task`` (or a later live ``/openral/prompt``);
401
+ deploy never derives it from scene tasks. Forwarded as
402
+ ``initial_task_prompt:=<text>`` to the launch file. Empty = the reasoner
403
+ idles until an operator prompt arrives."""
404
+ enable_reward_monitor: bool
405
+ """Whether the Robometer reward monitor is brought up
406
+ co-active with the VLA. When true the deploy preflight checks the VLA↔reward
407
+ VRAM pairing (:func:`_preflight_reward_vram_fit`) before bringing up ROS."""
408
+ reward_monitor_manifest: str
409
+ """The RESOLVED reward-monitor manifest path. Defaults from the
410
+ capability-matched VLA palette's ``reward_rskill_name`` (the pairing the
411
+ reasoner will honour) when ``--reward-monitor-manifest`` is not given; empty
412
+ when no reward monitor is active. Forwarded as ``reward_monitor_manifest:=…``."""
413
+ argv_template: list[str]
414
+ """``argv_template`` carries ``HAL_PARAMS_FILE_PLACEHOLDER`` where
415
+ the temp HAL-params YAML path goes. The dispatcher substitutes it
416
+ once the file exists."""
417
+
418
+
419
+ def _repo_root_from(start: Path) -> Path:
420
+ """Walk up from ``start`` until a directory with ``robots/`` and ``rskills/``."""
421
+ here = start.resolve()
422
+ for ancestor in (here, *here.parents):
423
+ if (ancestor / "robots").is_dir() and (ancestor / "rskills").is_dir():
424
+ return ancestor
425
+ raise ROSConfigError(
426
+ f"Could not locate OpenRAL repo root above {start}; "
427
+ "expected a parent containing both robots/ and rskills/."
428
+ )
429
+
430
+
431
+ def _load_scene_robot_id(config: Path) -> str | None:
432
+ """Return the ``robot_id`` declared in a DeployScene YAML, or None.
433
+
434
+ Strict DeployScene loading: ``openral deploy sim --config``
435
+ accepts a DeployScene YAML only (scene + optional robot, no task).
436
+ SimScene / BenchmarkScene YAMLs are rejected with a redirect message.
437
+
438
+ Two paths to discover the robot:
439
+
440
+ 1. ``robot_id:`` declared explicitly in the YAML → use it.
441
+ 2. The scene id is registered with a ``fixed_robot=`` in
442
+ ``openral_sim.SCENES`` (every robocasa kitchen / LIBERO / ALOHA
443
+ / MetaWorld / PushT scene) → look it up. Lets robocasa-shaped
444
+ YAMLs (which forbid ``robot_id:`` per the schema's free-axis
445
+ guard) still resolve into ``openral deploy sim`` without the
446
+ operator passing ``--robot`` redundantly.
447
+ """
448
+ from openral_core import DeployScene, load_scene_strict # reason: defer schema import
449
+
450
+ try:
451
+ env = load_scene_strict(str(config), DeployScene)
452
+ except (ROSConfigError, FileNotFoundError) as exc:
453
+ raise ROSConfigError(f"failed to load --config {config}: {exc}") from exc
454
+ if env.robot_id is not None:
455
+ return env.robot_id
456
+ # Fixed-robot scene fallback. The sim registry is the source of
457
+ # truth for which scene ids hard-fix a robot.
458
+ try:
459
+ from openral_sim import SCENES # reason: defer optional dep
460
+ except ImportError:
461
+ return None
462
+ if env.scene.id in SCENES:
463
+ return SCENES.fixed_robot(env.scene.id)
464
+ return None
465
+
466
+
467
+ def _scan_params_from_description(description: RobotDescription) -> dict[str, object]:
468
+ """Map a robot's ``lidar_2d`` sensor to HAL ``scan_*`` ROS params.
469
+
470
+ Single source of truth — ``openral deploy sim`` forwards these
471
+ to the HAL instead of hardcoding a per-robot scan envelope. Returns
472
+ an empty dict when the robot declares no LiDAR (non-mobile robots,
473
+ no scan synthesis), so the call site is a no-op for them.
474
+ """
475
+ lidar = description.lidar_sensor
476
+ if lidar is None:
477
+ return {}
478
+ params: dict[str, object] = {}
479
+ if lidar.rate_hz:
480
+ params["scan_publish_rate_hz"] = lidar.rate_hz
481
+ if lidar.n_channels is not None:
482
+ params["scan_n_beams"] = lidar.n_channels
483
+ if lidar.range_max_m is not None:
484
+ params["scan_max_range_m"] = lidar.range_max_m
485
+ if lidar.range_min_m is not None:
486
+ params["scan_min_range_m"] = lidar.range_min_m
487
+ return params
488
+
489
+
490
+ def _scene_backend_has_sim_clock(config: Path | None) -> bool:
491
+ """Return whether a DeployScene backend can be the OpenRAL simulation clock authority."""
492
+ if config is None:
493
+ return False
494
+ from openral_core import DeployScene, PhysicsBackend, load_scene_strict
495
+
496
+ scene = load_scene_strict(str(config), DeployScene)
497
+ return scene.scene.backend in {
498
+ PhysicsBackend.MUJOCO,
499
+ PhysicsBackend.MUJOCO_MJX,
500
+ PhysicsBackend.ISAACSIM,
501
+ PhysicsBackend.SAPIEN,
502
+ }
503
+
504
+
505
+ def _resolve_clock_origin(*, hal_mode: str, config: Path | None) -> str:
506
+ """Resolve the OpenRAL clock authority origin for the launch graph.
507
+
508
+ Real deployments use host wall time. Sim deployments use simulator elapsed
509
+ time when the deploy scene backend exposes a sim clock. Scene-attached HALs
510
+ and bare MuJoCo twins both expose ``sim_time_ns``; clock-less scenes stay in
511
+ host-wall time so ROS node clocks never pin at zero.
512
+ """
513
+ if hal_mode != "sim":
514
+ return "host_wall"
515
+ return "simulation" if _scene_backend_has_sim_clock(config) else "host_wall"
516
+
517
+
518
+ def _omdet_runtime_available() -> bool:
519
+ """True when the OmDet-Turbo continuous detector's runtime deps are importable.
520
+
521
+ The default object detector is omdet-turbo-indoor (open-vocabulary, grounds
522
+ arbitrary indoor/kitchen objects rather than the fixed COCO-80 of RT-DETR).
523
+ Its in-process zero-shot backend
524
+ (``openral_runner.backends.gstreamer.omdet_turbo_detector.OmDetTurboDetector``)
525
+ needs ``transformers`` + ``timm`` — the ``omdet`` dependency group. When they
526
+ are absent (a checkout that only synced the base group),
527
+ :func:`resolve_launch_invocation` gracefully falls back to the in-tree
528
+ RT-DETR COCO ONNX so ``deploy sim`` still brings up a detector instead of the
529
+ node hard-failing at backend build.
530
+
531
+ Patched in tests to exercise both branches without touching the environment.
532
+ """
533
+ # Local import: the probe is cheap and scoped to this one decision.
534
+ import importlib.util
535
+
536
+ return all(importlib.util.find_spec(mod) is not None for mod in ("transformers", "timm"))
537
+
538
+
539
+ def _object_detector_onnx_present(path: Path) -> bool:
540
+ """True when the fallback RT-DETR COCO ONNX weights are on disk.
541
+
542
+ The weights (``rskills/rtdetr-coco-r18/model.onnx``, ~2 MB) are gitignored, so
543
+ they are present on a weights-fetched dev host but absent in a bare CI
544
+ checkout. :func:`resolve_launch_invocation` downgrades the detector leg off
545
+ when neither omdet deps nor these weights can build a backend. Factored out so
546
+ tests can exercise the fallback-selection logic without the gitignored binary.
547
+ """
548
+ return path.is_file()
549
+
550
+
551
+ def _resolve_slam_backend(*, has_lidar: bool, has_vision_slam: bool, enable_slam: bool) -> str:
552
+ """Pick the SLAM backend the launch composes.
553
+
554
+ Returns one of ``"lidar"`` (slam_toolbox; needs ``/scan``), ``"visual"``
555
+ (cuVSLAM + nvblox; camera-based, for lidar-less robots), or ``"none"``.
556
+
557
+ ``has_lidar`` wins when both flags are set: the 2D-lidar leg is the
558
+ cheaper, proven path and needs no AI depth model. A resolved
559
+ ``enable_slam`` of ``False`` (an explicit ``--no-enable-slam``) forces
560
+ ``"none"`` so the forwarded ``slam_backend:=`` arg never contradicts the
561
+ ``enable_slam:=false`` arg.
562
+
563
+ Args:
564
+ has_lidar: ``RobotCapabilities.has_lidar``.
565
+ has_vision_slam: ``RobotCapabilities.has_vision_slam``.
566
+ enable_slam: The resolved SLAM-on decision (manifest auto or flag).
567
+
568
+ Returns:
569
+ The backend identifier forwarded as the ``slam_backend`` launch arg.
570
+
571
+ Example:
572
+ >>> _resolve_slam_backend(has_lidar=False, has_vision_slam=True, enable_slam=True)
573
+ 'visual'
574
+ >>> _resolve_slam_backend(has_lidar=True, has_vision_slam=True, enable_slam=True)
575
+ 'lidar'
576
+ >>> _resolve_slam_backend(has_lidar=True, has_vision_slam=False, enable_slam=False)
577
+ 'none'
578
+ """
579
+ if not enable_slam:
580
+ return "none"
581
+ if has_lidar:
582
+ return "lidar"
583
+ if has_vision_slam:
584
+ return "visual"
585
+ return "none"
586
+
587
+
588
+ def _memory_bundle_launch_args(memory_dir: str) -> list[str]:
589
+ """Derive the sim_e2e.launch.py bundle args from a deploy memory-bundle dir.
590
+
591
+ The bundle is a directory holding any of ``MEMORY.md`` (semantic memory),
592
+ ``scene_graph.json`` (3D world-state graph), and ``map.yaml`` (2D occupancy grid).
593
+ Each artifact is forwarded to its own consumer's launch arg. The dir must exist
594
+ (the robot writes ``MEMORY.md`` into it); ``scene_graph.json`` / ``map.yaml`` are
595
+ forwarded only when present, so a fresh bundle (empty dir) just starts the reasoner
596
+ with empty memory and no preloaded scene/map.
597
+ """
598
+ from openral_core.exceptions import ROSConfigError
599
+
600
+ d = Path(memory_dir).expanduser()
601
+ if not d.is_dir():
602
+ raise ROSConfigError(
603
+ f"--memory-dir {memory_dir!r} is not an existing directory. Create the bundle "
604
+ "dir first (the robot writes MEMORY.md into it; place scene_graph.json / map.yaml "
605
+ "there to preload the scene graph + occupancy grid)."
606
+ )
607
+ # memory_md_path may not exist yet — the reasoner creates it on the first memory_write.
608
+ args = [f"memory_md_path:={d / 'MEMORY.md'}"]
609
+ scene_graph = d / "scene_graph.json"
610
+ if scene_graph.is_file():
611
+ args.append(f"spatial_memory_path:={scene_graph}")
612
+ map_yaml = d / "map.yaml"
613
+ if map_yaml.is_file():
614
+ args.append(f"map_path:={map_yaml}")
615
+ return args
616
+
617
+
618
+ # the in-tree directory of the default reward/progress-monitor rSkill
619
+ # the deploy pairs with a VLA when nothing names one. Mirrors the reasoner's
620
+ # launch default (``rskills/robometer-4b/rskill.yaml``, sim_e2e.launch.py).
621
+ _DEFAULT_REWARD_RSKILL_DIR = "robometer-4b"
622
+
623
+
624
+ def _detect_gpu_vram_gb(field: str) -> float:
625
+ """VRAM (GB) of GPU 0 for an ``nvidia-smi`` field, or ``0.0`` when unavailable.
626
+
627
+ Torch-free probe (the CLI must not import torch just to size the GPU). Any
628
+ failure (no nvidia-smi, no GPU, parse error) returns ``0.0`` → the caller
629
+ skips the pair check rather than blocking a launch on a host where the budget
630
+ cannot be read. ``field`` is a ``--query-gpu`` column, e.g. ``memory.total`` or
631
+ ``memory.free``.
632
+ """
633
+ try:
634
+ out = subprocess.run(
635
+ ["nvidia-smi", f"--query-gpu={field}", "--format=csv,noheader,nounits"],
636
+ capture_output=True,
637
+ text=True,
638
+ timeout=5.0,
639
+ check=True,
640
+ )
641
+ except (OSError, subprocess.SubprocessError):
642
+ return 0.0
643
+ lines = out.stdout.strip().splitlines()
644
+ if not lines:
645
+ return 0.0
646
+ try:
647
+ return float(lines[0].strip()) / 1024.0 # MiB → GiB
648
+ except ValueError:
649
+ return 0.0
650
+
651
+
652
+ def _detect_gpu_free_vram_gb() -> float:
653
+ """Free VRAM (GB) of GPU 0 at launch — the real pre-load budget for the VLA+reward pair.
654
+
655
+ The launch preflight runs before any OpenRAL model is loaded, so *free* VRAM
656
+ (not total) is the honest headroom the VLA + reward pair must fit into. On a
657
+ shared dev box a desktop compositor or a sibling worktree's process can hold
658
+ GBs the pair will never see; budgeting against total would greenlight a pair
659
+ that OOMs the moment both models load (the failure mode `--no-enable-reward-monitor`
660
+ masks by dropping the reward model).
661
+ """
662
+ return _detect_gpu_vram_gb("memory.free")
663
+
664
+
665
+ def _capability_matched_manifests(
666
+ repo_root: Path,
667
+ description: RobotDescription,
668
+ *,
669
+ commercial_deployment: bool = False,
670
+ ) -> list[RSkillManifest]:
671
+ """In-tree rSkill manifests that match this robot's reasoner palette.
672
+
673
+ Loads every ``rskills/*/rskill.yaml`` and runs the same
674
+ capability/role/license filter the reasoner seeds at ``on_configure``
675
+ (:func:`openral_reasoner.palette.build_tool_palette`), returning the matched
676
+ manifests. ``openral deploy sim`` does not preselect a VLA — the reasoner picks
677
+ one at runtime from exactly this set — so reward resolution + the VRAM
678
+ preflight both reason over it (the "VLA known at launch" is the *palette*, not a
679
+ single policy). Unloadable manifests are skipped (the reasoner skips them too).
680
+ """
681
+ from openral_core import RSkillManifest
682
+ from openral_reasoner.palette import build_tool_palette
683
+
684
+ manifests: list[RSkillManifest] = []
685
+ for path in sorted((repo_root / "rskills").glob("*/rskill.yaml")):
686
+ try:
687
+ manifests.append(RSkillManifest.from_yaml(str(path)))
688
+ except (OSError, ValueError):
689
+ continue
690
+ if not manifests:
691
+ return []
692
+ palette = build_tool_palette(
693
+ installed_skills=manifests,
694
+ robot_capabilities=description.capabilities,
695
+ commercial_deployment=commercial_deployment,
696
+ )
697
+ matched = set(palette.execute_rskill_ids)
698
+ return [m for m in manifests if m.name in matched]
699
+
700
+
701
+ def _resolve_reward_monitor_manifest(
702
+ *,
703
+ repo_root: Path,
704
+ description: RobotDescription,
705
+ explicit_manifest: str | None,
706
+ ) -> str:
707
+ """Resolve the reward-monitor manifest, defaulting from the VLA pairing.
708
+
709
+ The pairing used to be implicit: the reward model was chosen by a flag
710
+ (``--reward-monitor-manifest``) wholly decoupled from the VLA the reasoner
711
+ picks. The pairing is recorded on the VLA manifest
712
+ (``reward_rskill_name``); this honours it at launch. Because ``deploy sim``
713
+ does not preselect a single VLA, we read the pairing across the
714
+ capability-matched VLA palette:
715
+
716
+ * An explicit ``--reward-monitor-manifest`` always wins (operator override).
717
+ * Else, if the palette's VLAs agree on a single ``reward_rskill_name``, resolve
718
+ that rSkill ``name`` to its in-tree manifest path.
719
+ * Else (no VLA names a reward model, the named model is not in-tree, or the
720
+ palette VLAs disagree) fall back to the deployment default
721
+ (``robometer-4b``). A disagreement is warned — the reasoner additionally
722
+ warns per-VLA at dispatch when a VLA's ``reward_rskill_name`` differs from
723
+ the loaded reward model.
724
+
725
+ Returns the resolved manifest path as a string (empty only when even the
726
+ default is missing from the tree).
727
+ """
728
+ if explicit_manifest:
729
+ return explicit_manifest
730
+
731
+ from openral_core import RSkillManifest
732
+
733
+ default_path = repo_root / "rskills" / _DEFAULT_REWARD_RSKILL_DIR / "rskill.yaml"
734
+ default = str(default_path.resolve()) if default_path.is_file() else ""
735
+
736
+ # name → manifest path for every in-tree kind:reward rSkill.
737
+ reward_index: dict[str, Path] = {}
738
+ for path in sorted((repo_root / "rskills").glob("*/rskill.yaml")):
739
+ try:
740
+ man = RSkillManifest.from_yaml(str(path))
741
+ except (OSError, ValueError):
742
+ continue
743
+ if man.kind == "reward":
744
+ reward_index[man.name] = path
745
+
746
+ named = {
747
+ m.reward_rskill_name
748
+ for m in _capability_matched_manifests(repo_root, description)
749
+ if m.kind == "vla" and m.reward_rskill_name
750
+ }
751
+ if not named:
752
+ return default
753
+ if len(named) > 1:
754
+ _console.print(
755
+ "[yellow]warning:[/yellow] capability-matched VLAs name different reward "
756
+ f"models {sorted(named)!r}; defaulting the reward monitor to "
757
+ f"{_DEFAULT_REWARD_RSKILL_DIR!r}. The reasoner re-checks each VLA's pairing "
758
+ "at dispatch."
759
+ )
760
+ return default
761
+ (target_name,) = tuple(named)
762
+ target_path = reward_index.get(target_name)
763
+ if target_path is None:
764
+ _console.print(
765
+ f"[yellow]warning:[/yellow] VLA(s) pair with reward model {target_name!r} "
766
+ " but no in-tree kind:reward rSkill declares that name; "
767
+ f"defaulting the reward monitor to {_DEFAULT_REWARD_RSKILL_DIR!r}."
768
+ )
769
+ return default
770
+ return str(target_path.resolve())
771
+
772
+
773
+ def _preflight_reward_vram_fit( # noqa: PLR0912 # reason: linear per-VLA classification (fit / oom / undeclared) + the three notify branches read clearest inline
774
+ *,
775
+ repo_root: Path,
776
+ description: RobotDescription,
777
+ reward_manifest_path: str,
778
+ gpu_budget_gb: float,
779
+ commercial_deployment: bool = False,
780
+ ) -> None:
781
+ """Fail fast before launch when no VLA can co-reside with the reward model.
782
+
783
+ A VLA emits no success signal of its own, so it must run with its reward model
784
+ resident alongside it. The reasoner enforces this per-VLA at
785
+ dispatch (``_refuse_unfittable_vla``) — but only *after* ROS is up. This is the
786
+ pre-LAUNCH gate: build the same capability-matched VLA palette the reasoner
787
+ will, and run :func:`openral_core.schemas.assert_vla_reward_fits` for each VLA
788
+ against the reward model + the GPU budget.
789
+
790
+ The contract mirrors :func:`_preflight_palette_deps`: it is advisory per-VLA
791
+ (the reasoner drops a non-fitting VLA from dispatch anyway) and a HARD gate only
792
+ when the palette would be empty of *runnable* policies — i.e. **no** matched VLA
793
+ can dispatch with the reward model resident. In that case the deploy could
794
+ actuate nothing, so we notify and ``typer.Exit(1)`` before bringing up ROS
795
+ instead of booting a graph that dispatches a VLA blind or OOMs mid-run.
796
+
797
+ ``gpu_budget_gb`` is *free* VRAM at launch (nothing of ours is loaded yet), not
798
+ total — so a desktop compositor or a sibling worktree's process holding GBs is
799
+ counted against the pair, which is the whole point (it's the difference between
800
+ a preflight that greenlights an OOM and one that catches it).
801
+
802
+ Skipped (returns) when ``gpu_budget_gb <= 0.0`` (budget unreadable — defer to the
803
+ reasoner's runtime check), when no reward model is active, or when the robot has
804
+ no capability-matched VLA palette to check.
805
+ """
806
+ if gpu_budget_gb <= 0.0 or not reward_manifest_path:
807
+ return
808
+ from openral_core import RSkillManifest
809
+ from openral_core.exceptions import ROSGPUMemoryError
810
+ from openral_core.schemas import assert_vla_reward_fits
811
+
812
+ try:
813
+ reward = RSkillManifest.from_yaml(reward_manifest_path)
814
+ except (OSError, ValueError) as exc:
815
+ _console.print(
816
+ f"[red]config error:[/red] reward monitor manifest {reward_manifest_path!r} "
817
+ f"failed to load (VLA+reward VRAM preflight): {exc}"
818
+ )
819
+ raise typer.Exit(code=1) from exc
820
+
821
+ vlas = [
822
+ m
823
+ for m in _capability_matched_manifests(
824
+ repo_root, description, commercial_deployment=commercial_deployment
825
+ )
826
+ if m.kind == "vla"
827
+ ]
828
+ if not vlas:
829
+ return
830
+
831
+ fits: list[str] = []
832
+ oom: list[str] = []
833
+ undeclared: list[str] = []
834
+ for vla in vlas:
835
+ try:
836
+ combined = assert_vla_reward_fits(vla, reward, gpu_budget_gb)
837
+ except ROSGPUMemoryError as exc:
838
+ oom.append(f"{vla.name}: {exc}")
839
+ except ROSConfigError:
840
+ # min_vram_gb undeclared for the active dtype — the pair cannot be
841
+ # verified, so the reasoner will refuse this VLA at dispatch too.
842
+ undeclared.append(vla.name)
843
+ else:
844
+ fits.append(f"{vla.name} ({combined:.2f} GB)")
845
+
846
+ if not fits:
847
+ _console.print()
848
+ _console.print(
849
+ "[red]preflight failed:[/red] no capability-matched VLA can co-reside with "
850
+ f"the reward model {reward.name!r} on this GPU "
851
+ f"({gpu_budget_gb:.2f} GB free at launch) — every paired policy would be "
852
+ "refused at dispatch, so the deploy could actuate nothing."
853
+ )
854
+ for line in oom:
855
+ _console.print(f" • too large: {line}")
856
+ if undeclared:
857
+ _console.print(
858
+ " • undeclared min_vram_gb (cannot verify the required co-residency): "
859
+ f"{undeclared!r}"
860
+ )
861
+ _console.print(
862
+ " Remedies: use a smaller-footprint VLA/reward pair or a larger GPU; "
863
+ "declare min_vram_gb on the VLA manifest(s); or run with "
864
+ "--no-enable-reward-monitor (accepting the VLA runs without a live reward "
865
+ "signal)."
866
+ )
867
+ raise typer.Exit(code=1)
868
+
869
+ if oom:
870
+ _console.print(
871
+ f"[yellow]preflight:[/yellow] {len(oom)} VLA(s) cannot fit beside the reward "
872
+ f"model {reward.name!r} in {gpu_budget_gb:.2f} GB free and will be refused at "
873
+ "dispatch:"
874
+ )
875
+ for line in oom:
876
+ _console.print(f" • {line}")
877
+ if undeclared:
878
+ _console.print(
879
+ f"[yellow]preflight:[/yellow] {len(undeclared)} VLA(s) do not declare "
880
+ "min_vram_gb for their active dtype, so the reward pairing cannot be "
881
+ f"verified and the reasoner will refuse them while a reward model is active "
882
+ f": {undeclared!r}"
883
+ )
884
+ _console.print(
885
+ f"[green]preflight:[/green] {len(fits)} VLA(s) fit beside reward "
886
+ f"{reward.name!r} in {gpu_budget_gb:.2f} GB free: {fits!r}"
887
+ )
888
+
889
+
890
+ def resolve_launch_invocation( # noqa: PLR0912, PLR0915 # reason: a flat resolve sequence (robot_id → manifest → per-feature slam/nav2/octomap + sim/real hal_mode gating); splitting hurts readability
891
+ *,
892
+ config: Path | None = None,
893
+ robot_override: str | None,
894
+ dashboard_port: int,
895
+ reset_to_pose_service: str | None,
896
+ approach_skill_id: str | None = None,
897
+ dataset_out: str | None = None,
898
+ dataset_repo_id: str | None = None,
899
+ dataset_license: str | None = None,
900
+ deploy_config: Path | None = None,
901
+ hal_param_overrides: dict[str, object] | None = None,
902
+ hal_mode: str = "sim",
903
+ enable_slam: bool | None = None,
904
+ enable_nav2: bool | None = None,
905
+ enable_octomap: bool | None = None,
906
+ enable_octomap_kernel_check: bool | None = None,
907
+ enable_object_detector: bool | None = None,
908
+ object_detector_onnx: Path | None = None,
909
+ object_detector_manifest: str | None = None,
910
+ object_detector_query: str | None = None,
911
+ enable_reward_monitor: bool | None = None,
912
+ reward_monitor_manifest: str | None = None,
913
+ reward_monitor_task: str | None = None,
914
+ enable_critic: bool | None = None,
915
+ object_detector_locators: list[str] | None = None,
916
+ spatial_memory_ingest: bool | None = None,
917
+ memory_dir: str | None = None,
918
+ enable_dashboard: bool = True,
919
+ enable_foxglove: bool = False,
920
+ foxglove_port: int = 8765,
921
+ initial_task_prompt: str | None = None,
922
+ ) -> LaunchInvocation:
923
+ """Resolve every input into the ``ros2 launch`` argv to execute.
924
+
925
+ Shared by ``openral deploy sim`` (``hal_mode="sim"``) and ``openral deploy
926
+ run`` (``hal_mode="real"``) from the same ``DeployScene`` config. In real
927
+ mode the sim-twin / scene-attach injections are skipped so the HAL node
928
+ builds the real hardware HAL via ``build_hal(mode="real")``.
929
+
930
+ Returned ``argv_template`` carries a ``HAL_PARAMS_FILE_PLACEHOLDER``
931
+ sentinel the caller substitutes after writing the ephemeral HAL params
932
+ YAML. No envelope file is ever written — the launch reads ``robot_yaml``
933
+ and feeds the kernel via ROS params.
934
+ """
935
+ from openral_core import DeployScene, RobotDescription # reason: defer schema import
936
+
937
+ if hal_mode not in ("sim", "real"):
938
+ raise ROSConfigError(f"hal_mode must be 'sim' or 'real', got {hal_mode!r}.")
939
+
940
+ deploy_scene = DeployScene.from_yaml(str(config)) if config is not None else None
941
+ scene_robot_id = (
942
+ deploy_scene.robot_id
943
+ if deploy_scene is not None and deploy_scene.robot_id is not None
944
+ else _load_scene_robot_id(config)
945
+ if config is not None
946
+ else None
947
+ )
948
+ robot_id = robot_override or scene_robot_id
949
+ if not robot_id:
950
+ raise ROSConfigError(
951
+ "robot_id is undefined: pass ``--robot <id>`` or (for ``deploy sim``) "
952
+ "set ``robot_id:`` in the DeployScene YAML / use a fixed-robot scene."
953
+ )
954
+
955
+ # The deploy startup prompt comes ONLY from the operator (--initial-task /
956
+ # a live /openral/prompt). Deploy never reads sim-predefined scene tasks —
957
+ # that is `sim run`'s job (deploy ≠ benchmark).
958
+ _resolved_initial_prompt: str = initial_task_prompt or ""
959
+
960
+ # DeployScene.runtime — the committed deploy posture. Field-by-field
961
+ # precedence: explicit CLI flag > scene runtime > auto/built-in default
962
+ # (the per-feature autos below). None on both = auto, as before.
963
+ rt = deploy_scene.runtime if deploy_scene is not None else None
964
+ if rt is not None:
965
+ scene_dir = config.parent if config is not None else None
966
+
967
+ def _scene_path(value: str | None) -> str | None:
968
+ # A relative path that exists next to the scene YAML resolves
969
+ # against it (CWD-independent committed workcells); anything else
970
+ # (alias, repo-relative default, hf:// URI) passes through verbatim.
971
+ if value and scene_dir is not None and not Path(value).is_absolute():
972
+ cand = (scene_dir / value).resolve()
973
+ if cand.exists():
974
+ return str(cand)
975
+ return value
976
+
977
+ enable_slam = enable_slam if enable_slam is not None else rt.enable_slam
978
+ enable_nav2 = enable_nav2 if enable_nav2 is not None else rt.enable_nav2
979
+ enable_octomap = enable_octomap if enable_octomap is not None else rt.enable_octomap
980
+ if enable_octomap_kernel_check is None:
981
+ enable_octomap_kernel_check = rt.enable_octomap_kernel_check
982
+ if enable_object_detector is None:
983
+ enable_object_detector = rt.enable_object_detector
984
+ if object_detector_onnx is None and rt.object_detector_onnx:
985
+ object_detector_onnx = Path(_scene_path(rt.object_detector_onnx) or "")
986
+ object_detector_manifest = object_detector_manifest or _scene_path(
987
+ rt.object_detector_manifest
988
+ )
989
+ object_detector_query = object_detector_query or rt.object_detector_query
990
+ if object_detector_locators is None:
991
+ object_detector_locators = rt.object_detector_locators
992
+ if enable_reward_monitor is None:
993
+ enable_reward_monitor = rt.enable_reward_monitor
994
+ reward_monitor_manifest = reward_monitor_manifest or _scene_path(rt.reward_monitor_manifest)
995
+ reward_monitor_task = reward_monitor_task or rt.reward_monitor_task
996
+ if enable_critic is None:
997
+ enable_critic = rt.enable_critic
998
+ if spatial_memory_ingest is None:
999
+ spatial_memory_ingest = rt.spatial_memory_ingest
1000
+ approach_skill_id = approach_skill_id or rt.approach_skill_id
1001
+ # Built-in defaults for the tri-state flags nothing pinned.
1002
+ if enable_octomap_kernel_check is None:
1003
+ enable_octomap_kernel_check = True
1004
+ if enable_reward_monitor is None:
1005
+ enable_reward_monitor = False
1006
+ if enable_critic is None:
1007
+ enable_critic = False
1008
+
1009
+ # a --robot override that differs from the scene's declared robot
1010
+ # composes a different arm than the scene was authored for. The scene's cameras
1011
+ # + asset mounts (e.g. tabletop_push's wrist_camera_mount_body="gripper") are
1012
+ # tuned for the declared robot, so on the override they may be mis-mounted or
1013
+ # unmatched — some /openral/cameras/* topics will be empty. Warn loudly.
1014
+ if robot_override and scene_robot_id and robot_override != scene_robot_id:
1015
+ _console.print(
1016
+ f"[yellow]warning:[/yellow] --robot {robot_override!r} overrides the scene's "
1017
+ f"declared robot_id {scene_robot_id!r}; the scene's cameras + asset mounts are "
1018
+ f"authored for {scene_robot_id!r} and may not match {robot_override!r} (expect "
1019
+ "empty /openral/cameras/* for non-matching sensors). Override only with a "
1020
+ "kinematically-compatible arm on a free-axis scene."
1021
+ )
1022
+
1023
+ hal = _ROBOT_HAL_REGISTRY.get(robot_id)
1024
+ if hal is None:
1025
+ supported = ", ".join(sorted(_ROBOT_HAL_REGISTRY))
1026
+ raise ROSConfigError(
1027
+ f"robot {robot_id!r} has no HAL entry in _ROBOT_HAL_REGISTRY. "
1028
+ f"Supported: {supported}. The registry key matches the "
1029
+ "``robots/<robot_id>/`` directory name — e.g. ``--robot "
1030
+ "franka_panda`` (not ``--robot franka``). To add a new robot, "
1031
+ "ship a ROS ``openral_hal_<X>`` package with a "
1032
+ "``lifecycle_node.py`` executable, add it to the ros2-build "
1033
+ "Justfile target, and register it here."
1034
+ )
1035
+
1036
+ repo_root = _repo_root_from(Path(__file__))
1037
+ robot_yaml = repo_root / "robots" / robot_id / "robot.yaml"
1038
+ if not robot_yaml.is_file():
1039
+ raise ROSConfigError(
1040
+ f"robot manifest not found at {robot_yaml}; expected robots/{robot_id}/robot.yaml."
1041
+ )
1042
+
1043
+ # Validate the manifest carries the e2e fields (joint position /
1044
+ # velocity / effort limits) and assert the manifest's declared name
1045
+ # is one the registered HAL accepts — protects against a typo
1046
+ # routing the wrong HAL at a robot.
1047
+ description = RobotDescription.from_yaml(str(robot_yaml))
1048
+ description.validate_for_e2e_pipeline()
1049
+
1050
+ # fail fast before shelling the launch if real mode is asked of
1051
+ # a simulation-only robot (better UX than a graph that dies at HAL
1052
+ # configure with the same ROSCapabilityMismatch).
1053
+ if hal_mode == "real" and description.hal.real is None:
1054
+ raise ROSCapabilityMismatch(
1055
+ f"robot {robot_id!r} has no real-hardware HAL (hal.real is null); it is "
1056
+ "simulation-only. Use `openral deploy sim` instead of `openral deploy run`."
1057
+ )
1058
+
1059
+ # SLAM is ON BY DEFAULT for every robot that *can* run it:
1060
+ # i.e. one that declares a lidar (the scan source slam_toolbox needs). This
1061
+ # is the firm default — a SLAM-capable robot always brings up the `map` frame
1062
+ # the object lift / spatial-memory ingest depend on, unless the operator
1063
+ # opts out with `--no-enable-slam`. Fixed-base arms (no mobile base, no lidar)
1064
+ # correctly stay off — there is no base to localise and nothing to map.
1065
+ # `enable_slam is None` means "auto": honour the manifest; an explicit flag wins.
1066
+ # SLAM is on for any robot that can localise/map: a lidar
1067
+ # (slam_toolbox) OR camera-based visual SLAM (cuVSLAM+nvblox, for
1068
+ # lidar-less robots). Fixed-base arms with neither correctly stay off.
1069
+ if enable_slam is None:
1070
+ enable_slam = bool(
1071
+ description.capabilities.has_lidar or description.capabilities.has_vision_slam
1072
+ )
1073
+ # backend selection (pure helper, unit-tested directly).
1074
+ slam_backend = _resolve_slam_backend(
1075
+ has_lidar=bool(description.capabilities.has_lidar),
1076
+ has_vision_slam=bool(description.capabilities.has_vision_slam),
1077
+ enable_slam=enable_slam,
1078
+ )
1079
+ # Nav2 auto-enables alongside slam_toolbox: every
1080
+ # lidar-equipped mobile robot needs a planner to consume the map.
1081
+ # Operators that want the map alone (recording / inspection) pass
1082
+ # ``--no-enable-nav2``.
1083
+ if enable_nav2 is None:
1084
+ enable_nav2 = enable_slam
1085
+ # the octomap world-collision leg auto-enables when the
1086
+ # robot manifest declares a usable depth SensorSpec (a camera the HAL
1087
+ # can ray-cast a PointCloud2 from); there is nothing to map otherwise.
1088
+ # ``--enable-octomap`` / ``--no-enable-octomap`` overrides.
1089
+ if enable_octomap is None:
1090
+ enable_octomap = any(
1091
+ s.modality in ("depth", "point_cloud") and s.intrinsics is not None
1092
+ for s in description.sensors
1093
+ )
1094
+ clock_origin = _resolve_clock_origin(hal_mode=hal_mode, config=config)
1095
+
1096
+ # The object-detection leg is ON by default (deploy sim is a
1097
+ # perception-driven stack; ``--no-object-detector`` turns it off). The default
1098
+ # backend is the open-vocabulary ``omdet-turbo-indoor`` continuous detector,
1099
+ # which grounds arbitrary indoor/kitchen objects instead of the fixed COCO-80
1100
+ # of RT-DETR; when its runtime deps (transformers/timm — the ``omdet`` group)
1101
+ # are not importable we gracefully fall back to the in-tree RT-DETR COCO ONNX
1102
+ # so the graph still comes up. Explicit ``--object-detector-manifest`` /
1103
+ # ``--object-detector-onnx`` override the default selection.
1104
+ default_rtdetr_onnx = repo_root / "rskills" / "rtdetr-coco-r18" / "model.onnx"
1105
+ default_omdet_manifest = repo_root / "rskills" / "omdet-turbo-indoor" / "rskill.yaml"
1106
+ if object_detector_manifest:
1107
+ resolved_object_detector_manifest = str(Path(object_detector_manifest).resolve())
1108
+ resolved_object_detector_onnx = (
1109
+ object_detector_onnx.resolve()
1110
+ if object_detector_onnx is not None
1111
+ else default_rtdetr_onnx
1112
+ )
1113
+ elif object_detector_onnx is not None:
1114
+ # Explicit ONNX → the legacy fixed-label RT-DETR path (no manifest).
1115
+ resolved_object_detector_manifest = ""
1116
+ resolved_object_detector_onnx = object_detector_onnx.resolve()
1117
+ elif _omdet_runtime_available():
1118
+ resolved_object_detector_manifest = str(default_omdet_manifest.resolve())
1119
+ resolved_object_detector_onnx = default_rtdetr_onnx
1120
+ else:
1121
+ # Graceful fallback: omdet deps absent → in-tree RT-DETR COCO-80 ONNX.
1122
+ resolved_object_detector_manifest = ""
1123
+ resolved_object_detector_onnx = default_rtdetr_onnx
1124
+
1125
+ if enable_object_detector is None:
1126
+ enable_object_detector = True
1127
+ # Downgrade to off (rather than let the node hard-fail at backend build) when
1128
+ # the detector is requested but no usable backend is present — a checkout that
1129
+ # has neither the omdet deps nor the gitignored RT-DETR ONNX weights.
1130
+ if (
1131
+ enable_object_detector
1132
+ and not resolved_object_detector_manifest
1133
+ and not _object_detector_onnx_present(resolved_object_detector_onnx)
1134
+ ):
1135
+ _console.print(
1136
+ "[yellow]object detector requested but no backend is available[/yellow] "
1137
+ "(omdet deps not importable and RT-DETR ONNX missing at "
1138
+ f"{resolved_object_detector_onnx}); disabling the detector leg. Run "
1139
+ "`just sync --group omdet --inexact` for the open-vocab default, fetch "
1140
+ "the RT-DETR weights, or pass --no-object-detector to silence."
1141
+ )
1142
+ enable_object_detector = False
1143
+
1144
+ # When the leg is off (explicit --no-object-detector or the downgrade above),
1145
+ # do not forward a continuous-detector manifest: ros2 launch would otherwise
1146
+ # carry a manifest for a node that never starts (and, with omdet deps present,
1147
+ # a non-empty manifest:= for a disabled leg is simply wrong).
1148
+ if not enable_object_detector:
1149
+ resolved_object_detector_manifest = ""
1150
+
1151
+ # on-demand open-vocab locators co-resident alongside the
1152
+ # continuous detector. Each token is a manifest path (``…/rskill.yaml``) or a
1153
+ # short alias resolved to ``rskills/<alias>/rskill.yaml``; the launch builds one
1154
+ # namespaced locate_in_view node per entry so the reasoner can pick a model.
1155
+ # Default = omdet-turbo-locator when the detector is on and the omdet deps are
1156
+ # importable (LocateAnything is opt-in — NVIDIA non-commercial, 5 GB VRAM).
1157
+ resolved_object_detector_locators: list[str] = []
1158
+ # An explicit ``--object-detector-locator`` is an independent grounding source —
1159
+ # honour it even with ``--no-object-detector`` (a lean deploy that grounds via
1160
+ # the on-demand locator alone, no always-on detector holding VRAM). The implicit
1161
+ # omdet-turbo-locator default only applies when the continuous detector is on.
1162
+ if object_detector_locators is not None:
1163
+ locator_tokens = object_detector_locators
1164
+ elif enable_object_detector and _omdet_runtime_available():
1165
+ locator_tokens = ["omdet-turbo-locator"]
1166
+ else:
1167
+ locator_tokens = []
1168
+ for token in locator_tokens:
1169
+ candidate = Path(token)
1170
+ manifest_path = (
1171
+ candidate
1172
+ if candidate.suffix == ".yaml"
1173
+ else repo_root / "rskills" / token / "rskill.yaml"
1174
+ )
1175
+ resolved_object_detector_locators.append(str(manifest_path.resolve()))
1176
+
1177
+ # auto-enable durable spatial-memory ingest whenever the object
1178
+ # detector runs (the producer that feeds it); an explicit flag overrides.
1179
+ if spatial_memory_ingest is None:
1180
+ spatial_memory_ingest = enable_object_detector
1181
+
1182
+ if description.name not in hal.supported_robot_names:
1183
+ raise ROSConfigError(
1184
+ f"HAL/robot mismatch: robot_id={robot_id!r} (registry → "
1185
+ f"{hal.package!r}) declares supported_robot_names="
1186
+ f"{sorted(hal.supported_robot_names)}, but "
1187
+ f"{robot_yaml} has name={description.name!r}. Either fix the "
1188
+ "manifest's `name:` field or register the right HAL for this "
1189
+ "robot in openral_cli.deploy_sim._ROBOT_HAL_REGISTRY."
1190
+ )
1191
+
1192
+ hal_params: dict[str, object] = {**hal.default_params}
1193
+ # a DeployScene ``hal:`` binding carries the workcell's
1194
+ # host-specific HAL defaults (serial ``port`` + lerobot ``id`` /
1195
+ # ``calibration_dir`` + ``calibrate_on_connect``), the HAL analogue of a
1196
+ # sensor ``deploy_binding``, so ``deploy run --config <scene>`` is
1197
+ # self-contained (no ``--hal`` needed). Merged above the robot-manifest
1198
+ # defaults (which the HAL node reads from ``robot.yaml``) but below any
1199
+ # explicit ``--hal`` override. A relative ``calibration_dir`` resolves
1200
+ # against the scene file's dir — mirrors the ``--hal calibration_dir=``
1201
+ # handling in ``main.deploy_run`` so a committed calibration works from any CWD.
1202
+ if deploy_scene is not None and deploy_scene.hal is not None and config is not None:
1203
+ scene_hal = dict(deploy_scene.hal.defaults)
1204
+ _scene_cal_dir = scene_hal.get("calibration_dir")
1205
+ if (
1206
+ isinstance(_scene_cal_dir, str)
1207
+ and _scene_cal_dir
1208
+ and not Path(_scene_cal_dir).is_absolute()
1209
+ ):
1210
+ scene_hal["calibration_dir"] = str((config.parent / _scene_cal_dir).resolve())
1211
+ hal_params.update(scene_hal)
1212
+ if hal_param_overrides:
1213
+ hal_params.update(hal_param_overrides)
1214
+
1215
+ # derive the /scan envelope from robot.yaml's lidar_2d
1216
+ # sensor (single source of truth) instead of hardcoding it in the
1217
+ # HAL registry. ``setdefault`` so an explicit ``--hal scan_*=…``
1218
+ # operator override still wins.
1219
+ for _scan_key, _scan_value in _scan_params_from_description(description).items():
1220
+ hal_params.setdefault(_scan_key, _scan_value)
1221
+
1222
+ # forward the sim config to HALs that declare
1223
+ # `sim_env_yaml` support. Gated on the per-HAL opt-in flag because
1224
+ # rclpy rejects unknown parameters at startup
1225
+ # (`automatically_declare_parameters_from_overrides=False` is the
1226
+ # default), so blanket-forwarding would break every other HAL.
1227
+ if hal.supports_sim_env_yaml and hal_mode == "sim" and config is not None:
1228
+ hal_params.setdefault("sim_env_yaml", str(config.resolve()))
1229
+
1230
+ # manifest-driven nodes build their HAL via build_hal(mode).
1231
+ # `deploy sim` → hal_mode="sim"; `deploy run` → hal_mode="real". The node
1232
+ # raises ROSCapabilityMismatch for a sim-only-vs-real mismatch.
1233
+ if hal.manifest_driven:
1234
+ hal_params.setdefault("robot_yaml", str(robot_yaml))
1235
+ hal_params.setdefault("hal_mode", hal_mode)
1236
+ # deploy sim is inherently a scene; forward the resolved
1237
+ # config so the manifest-driven node scene-attaches (SimAttachedHAL)
1238
+ # instead of building a bare twin. Sim mode only; real never attaches.
1239
+ # `bare_twin_sim` arms (so100 / so101) opt out: they build a bare
1240
+ # `MujocoArmHAL` twin from their `sim:` block (issue #191 Phase 2),
1241
+ # preserving the pre-migration `supports_sim_robot_yaml` behaviour.
1242
+ if hal_mode == "sim" and config is not None and not hal.bare_twin_sim:
1243
+ hal_params.setdefault("sim_env_yaml", str(config.resolve()))
1244
+ # forward the DeployScene's own MJCF composition (its arena)
1245
+ # to the manifest-driven node so the SCENE owns its environment instead
1246
+ # of the robot manifest. Sim-mode bare-twin robots only (scene-attach
1247
+ # robots build the scene's SimRollout directly via sim_env_yaml).
1248
+ if hal_mode == "sim" and deploy_scene is not None and hal.bare_twin_sim:
1249
+ scene_composition = deploy_scene.composition
1250
+ if scene_composition is not None:
1251
+ hal_params.setdefault("scene_composition_json", scene_composition.model_dump_json())
1252
+
1253
+ service = reset_to_pose_service or f"/openral/{robot_id}/reset_to_pose"
1254
+ # Empty by default — the legacy ResetToPose snap stays until a move_group is
1255
+ # wired into the graph; opt in with --approach-skill-id.
1256
+ approach_skill = approach_skill_id or ""
1257
+
1258
+ argv_template: list[str] = [
1259
+ "ros2",
1260
+ "launch",
1261
+ "openral_rskill_ros",
1262
+ "sim_e2e.launch.py",
1263
+ f"robot_yaml:={robot_yaml}",
1264
+ f"hal_package:={hal.package}",
1265
+ f"hal_executable:={hal.executable}",
1266
+ f"hal_node_name:={hal.node_name}",
1267
+ "hal_params_file:=HAL_PARAMS_FILE_PLACEHOLDER",
1268
+ f"reset_to_pose_service:={service}",
1269
+ f"dashboard_port:={dashboard_port}",
1270
+ # forward the deploy path so the reasoner's action-mode
1271
+ # palette gate matches the HAL this graph brings up. ``deploy sim``
1272
+ # → ``hal_mode="sim"`` (default; the scene's robosuite OSC controller
1273
+ # synthesises cartesian/OSC modes); ``deploy run`` → ``"real"``.
1274
+ f"hal_mode:={hal_mode}",
1275
+ f"enable_slam:={'true' if enable_slam else 'false'}",
1276
+ f"slam_backend:={slam_backend}",
1277
+ f"enable_nav2:={'true' if enable_nav2 else 'false'}",
1278
+ f"enable_octomap:={'true' if enable_octomap else 'false'}",
1279
+ f"enable_octomap_kernel_check:={'true' if enable_octomap_kernel_check else 'false'}",
1280
+ # OpenRAL clock authority. The launch maps this to ROS
1281
+ # use_sim_time internally: simulation → use_sim_time=true + HAL /clock;
1282
+ # host_wall → system time and no OpenRAL /clock publisher.
1283
+ f"clock_origin:={clock_origin}",
1284
+ f"enable_object_detector:={'true' if enable_object_detector else 'false'}",
1285
+ f"object_detector_onnx:={resolved_object_detector_onnx}",
1286
+ # reward monitor co-active with the VLA; the reasoner polls
1287
+ # /openral/perception/query_task_progress when task_progress_available.
1288
+ f"enable_reward_monitor:={'true' if enable_reward_monitor else 'false'}",
1289
+ # Tier-C critic producer; emits FailureTrigger on
1290
+ # /openral/failure/critic when a reward model's score stalls.
1291
+ f"enable_critic:={'true' if enable_critic else 'false'}",
1292
+ f"spatial_memory_ingest:={'true' if spatial_memory_ingest else 'false'}",
1293
+ f"enable_dashboard:={'true' if enable_dashboard else 'false'}",
1294
+ # read-only Foxglove live-scene bridge. Off by default;
1295
+ # ``--foxglove`` opts in. The bridge starts after the topic producers
1296
+ # (HAL, SLAM, octomap, robot_state_publisher) via a TimerAction in the
1297
+ # launch to avoid the foxglove-sdk-cpp v0.18.0 stale-bridge bug.
1298
+ f"enable_foxglove:={'true' if enable_foxglove else 'false'}",
1299
+ f"foxglove_port:={foxglove_port}",
1300
+ ]
1301
+ # ``ros2 launch`` rejects an empty ``name:=`` argument, so only forward the
1302
+ # optional detector overrides when set; the launch file defaults both to "".
1303
+ if resolved_object_detector_manifest:
1304
+ argv_template.append(f"object_detector_manifest:={resolved_object_detector_manifest}")
1305
+ if object_detector_query:
1306
+ argv_template.append(f"object_detector_query:={object_detector_query}")
1307
+ # resolve the reward-monitor manifest from the VLA pairing when
1308
+ # the operator did not pin one. ``deploy sim`` does not preselect a VLA, so the
1309
+ # default is derived from the capability-matched VLA palette's
1310
+ # ``reward_rskill_name`` (the pairing the reasoner will honour) instead of an
1311
+ # independent flag. Only when the reward monitor is active; otherwise empty.
1312
+ resolved_reward_monitor_manifest = (
1313
+ _resolve_reward_monitor_manifest(
1314
+ repo_root=repo_root,
1315
+ description=description,
1316
+ explicit_manifest=reward_monitor_manifest,
1317
+ )
1318
+ if enable_reward_monitor
1319
+ else ""
1320
+ )
1321
+ # Forward the resolved reward manifest (ros2 launch rejects an empty ``name:=``
1322
+ # value; the launch file defaults it to "" and the reasoner/monitor fall back
1323
+ # to the robometer default when unset).
1324
+ if resolved_reward_monitor_manifest:
1325
+ argv_template.append(f"reward_monitor_manifest:={resolved_reward_monitor_manifest}")
1326
+ # The reward monitor's always-on critic_score path scores against its
1327
+ # `task` param; an empty task makes `_publish_critic_score` silently skip
1328
+ # every tick. Default
1329
+ # it to the operator goal so a deploy with `--initial-task` gets a
1330
+ # background progress signal out of the box (an explicit
1331
+ # `--reward-monitor-task` still wins; the reasoner's `query_task_progress`
1332
+ # polls already carry the live subtask when it dispatches with a deadline).
1333
+ effective_reward_task = reward_monitor_task
1334
+ if not effective_reward_task and _resolved_initial_prompt:
1335
+ effective_reward_task = _resolved_initial_prompt.strip()
1336
+ if effective_reward_task:
1337
+ argv_template.append(f"reward_monitor_task:={effective_reward_task}")
1338
+ # only forward the locator list when non-empty (ros2 launch rejects
1339
+ # an empty ``name:=`` value; the launch file defaults it to "").
1340
+ if resolved_object_detector_locators:
1341
+ argv_template.append(
1342
+ "object_detector_locators:=" + ",".join(resolved_object_detector_locators)
1343
+ )
1344
+ # only forward the approach skill when opted in (empty default;
1345
+ # ros2 launch rejects an empty ``name:=`` value, and the launch file
1346
+ # defaults ``approach_skill_id`` to "").
1347
+ if approach_skill:
1348
+ argv_template.append(f"approach_skill_id:={approach_skill}")
1349
+
1350
+ # only forward the dataset args when recording is opted in
1351
+ # (empty defaults; ros2 launch rejects an empty ``name:=`` value, and the
1352
+ # launch file defaults all three so omitting them disables recording).
1353
+ if dataset_out:
1354
+ argv_template.append(f"dataset_out:={dataset_out}")
1355
+ if dataset_repo_id:
1356
+ argv_template.append(f"dataset_repo_id:={dataset_repo_id}")
1357
+ if dataset_license:
1358
+ argv_template.append(f"dataset_license:={dataset_license}")
1359
+
1360
+ if deploy_scene is not None and (
1361
+ deploy_scene.safety is not None or deploy_scene.extra_allowed_collision_pairs
1362
+ ):
1363
+ argv_template.append(f"workcell_json:={deploy_scene.model_dump_json(exclude_unset=True)}")
1364
+
1365
+ # The deploy memory bundle. ``--memory-dir`` (CLI) wins;
1366
+ # otherwise the DeployScene's own ``memory_dir`` field. Derive the per-modality
1367
+ # launch paths by convention and forward them (each to its consumer's arg).
1368
+ effective_memory_dir = memory_dir
1369
+ if effective_memory_dir is None and deploy_scene is not None:
1370
+ effective_memory_dir = deploy_scene.memory_dir
1371
+ if effective_memory_dir:
1372
+ argv_template.extend(_memory_bundle_launch_args(effective_memory_dir))
1373
+
1374
+ # Forward the startup prompt only when non-empty. The launch
1375
+ # file defaults ``initial_task_prompt`` to "" (no prompt), so omitting it
1376
+ # leaves the reasoner in idle mode until an operator prompt arrives.
1377
+ if _resolved_initial_prompt:
1378
+ argv_template.append(f"initial_task_prompt:={_resolved_initial_prompt}")
1379
+
1380
+ # Real deploys — forward the DeployScene YAML so the runtime node
1381
+ # opens every deploy-bound sensor (robot manifest + scene `sensors:`)
1382
+ # and publishes the physical cameras onto
1383
+ # /openral/cameras/<name>/image (sim keeps the HAL bridge as the
1384
+ # only camera source; empty default in the launch file).
1385
+ if deploy_config is not None and hal_mode == "real":
1386
+ argv_template.append(f"deploy_config:={Path(deploy_config).resolve()}")
1387
+
1388
+ return LaunchInvocation(
1389
+ robot_id=robot_id,
1390
+ robot_yaml=robot_yaml,
1391
+ robot_manifest_name=description.name,
1392
+ hal=hal,
1393
+ enable_slam=enable_slam,
1394
+ slam_backend=slam_backend,
1395
+ enable_nav2=enable_nav2,
1396
+ enable_octomap=enable_octomap,
1397
+ clock_origin=clock_origin,
1398
+ enable_object_detector=enable_object_detector,
1399
+ object_detector_onnx=resolved_object_detector_onnx,
1400
+ object_detector_manifest=resolved_object_detector_manifest,
1401
+ object_detector_query=object_detector_query or "",
1402
+ object_detector_locators=tuple(resolved_object_detector_locators),
1403
+ spatial_memory_ingest=spatial_memory_ingest,
1404
+ hal_params=hal_params,
1405
+ hal_mode=hal_mode,
1406
+ reset_to_pose_service=service,
1407
+ approach_skill_id=approach_skill,
1408
+ enable_foxglove=enable_foxglove,
1409
+ foxglove_port=foxglove_port,
1410
+ initial_task_prompt=_resolved_initial_prompt,
1411
+ enable_reward_monitor=enable_reward_monitor,
1412
+ reward_monitor_manifest=resolved_reward_monitor_manifest,
1413
+ argv_template=argv_template,
1414
+ )
1415
+
1416
+
1417
+ def _prepare_launch_env() -> dict[str, str]:
1418
+ """Build the environment for the ``ros2 launch`` subprocess (deploy sim + deploy run).
1419
+
1420
+ Shared by both shelling paths so the wiring stays identical:
1421
+
1422
+ * Export ``OPENRAL_VENV_SITE`` + prepend the venv site / bin so the launch
1423
+ parser and every spawned node import ``openral_core`` from the workspace
1424
+ venv (the editable ``.pth`` files are processed via ``site.py``).
1425
+ * Default the **expandable-segments CUDA allocator**. The
1426
+ ``runtime_node`` loads VLA weights (pi05 / molmoact2 …) onto the GPU; on a
1427
+ tight 8 GiB card the default allocator fragments and OOMs at the forward
1428
+ pass even for an NF4 model that otherwise fits (molmoact2-libero-nf4 peaks
1429
+ ~7.6 GiB; without this it dies on a small alloc with ~165 MiB stuck in
1430
+ reserved-but-unallocated). ``setdefault`` so an operator override wins;
1431
+ both env-var spellings are set because the name was renamed across torch
1432
+ releases (``PYTORCH_CUDA_ALLOC_CONF`` → ``PYTORCH_ALLOC_CONF``).
1433
+ * Clean stale Fast-DDS SHM (``_apply_rmw_default``).
1434
+ """
1435
+ env = os.environ.copy()
1436
+ venv_site = sysconfig.get_paths()["purelib"]
1437
+ env["OPENRAL_VENV_SITE"] = venv_site
1438
+ existing = env.get("PYTHONPATH", "")
1439
+ env["PYTHONPATH"] = f"{venv_site}{os.pathsep}{existing}" if existing else venv_site
1440
+ venv_bin = os.path.dirname(sys.executable)
1441
+ existing_path = env.get("PATH", "")
1442
+ env["PATH"] = f"{venv_bin}{os.pathsep}{existing_path}" if existing_path else venv_bin
1443
+ env.setdefault("PYTORCH_ALLOC_CONF", "expandable_segments:True")
1444
+ env.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
1445
+ _apply_rmw_default(env)
1446
+ return env
1447
+
1448
+
1449
+ def run_launch_invocation(invocation: LaunchInvocation, *, run_preflight: bool = True) -> int:
1450
+ """Write the HAL params YAML, set the venv env, and shell ``ros2 launch``.
1451
+
1452
+ Returns the launch exit code. The shared shelling path used by both
1453
+ ``openral deploy sim`` and ``openral deploy run``
1454
+ . Exports ``OPENRAL_VENV_SITE`` + prepends the venv bin/PATH so
1455
+ the launch parser and spawned nodes import ``openral_core`` from the
1456
+ workspace venv (the editable ``.pth`` files are processed via ``site.py``).
1457
+ ``run_preflight`` probes the rSkill palette extras.
1458
+ """
1459
+ if run_preflight:
1460
+ repo_root = _repo_root_from(Path(__file__))
1461
+ _preflight_palette_deps(
1462
+ repo_root=repo_root,
1463
+ robot_yaml=Path(invocation.robot_yaml),
1464
+ )
1465
+ # VLA↔reward VRAM pair preflight (deploy run path). No-op
1466
+ # unless a reward monitor is active and the GPU budget is readable.
1467
+ if invocation.enable_reward_monitor and invocation.reward_monitor_manifest:
1468
+ from openral_core import RobotDescription
1469
+
1470
+ _preflight_reward_vram_fit(
1471
+ repo_root=repo_root,
1472
+ description=RobotDescription.from_yaml(str(invocation.robot_yaml)),
1473
+ reward_manifest_path=invocation.reward_monitor_manifest,
1474
+ gpu_budget_gb=_detect_gpu_free_vram_gb(),
1475
+ )
1476
+ hal_params_tmp = tempfile.NamedTemporaryFile( # noqa: SIM115 # reason: HAL reads after this scope
1477
+ mode="w",
1478
+ prefix=f"openral-hal-params-{invocation.robot_id}-",
1479
+ suffix=".yaml",
1480
+ delete=False,
1481
+ encoding="utf-8",
1482
+ )
1483
+ try:
1484
+ yaml.safe_dump(
1485
+ {"/**": {"ros__parameters": invocation.hal_params}},
1486
+ hal_params_tmp,
1487
+ sort_keys=False,
1488
+ )
1489
+ hal_params_tmp.close()
1490
+ argv = [
1491
+ arg.replace("HAL_PARAMS_FILE_PLACEHOLDER", hal_params_tmp.name)
1492
+ for arg in invocation.argv_template
1493
+ ]
1494
+ _console.print(f" hal_params_tmp:{hal_params_tmp.name}")
1495
+ _console.print(f" argv: {shlex.join(argv)}")
1496
+ venv_env = _prepare_launch_env()
1497
+ return _run_launch(argv, venv_env)
1498
+ finally:
1499
+ with contextlib.suppress(OSError):
1500
+ Path(hal_params_tmp.name).unlink(missing_ok=True)
1501
+
1502
+
1503
+ def _parse_hal_overrides(raw: list[str] | None) -> dict[str, object]:
1504
+ """Parse ``--hal key=value`` flags into a typed override dict.
1505
+
1506
+ Accepts bool / int / float / string. JSON-encoded values are tried
1507
+ first so ``--hal cameras='["top"]'`` works.
1508
+ """
1509
+ out: dict[str, object] = {}
1510
+ for entry in raw or []:
1511
+ if "=" not in entry:
1512
+ raise ROSConfigError(f"--hal {entry!r} is malformed; expected ``key=value``.")
1513
+ key, value = entry.split("=", 1)
1514
+ key = key.strip()
1515
+ value = value.strip()
1516
+ try:
1517
+ out[key] = json.loads(value)
1518
+ except json.JSONDecodeError:
1519
+ out[key] = value
1520
+ return out
1521
+
1522
+
1523
+ def _ros2_pkg_prefix(pkg: str) -> str | None:
1524
+ """Return the install prefix for ``pkg`` per ``ros2 pkg prefix``, or None.
1525
+
1526
+ Uses ``ros2 pkg prefix`` because it is the same lookup ``ros2 launch``
1527
+ performs internally. A package is "discoverable" iff this exits 0
1528
+ and prints a path.
1529
+ """
1530
+ try:
1531
+ completed = subprocess.run(
1532
+ ["ros2", "pkg", "prefix", pkg],
1533
+ check=False,
1534
+ capture_output=True,
1535
+ text=True,
1536
+ timeout=10,
1537
+ )
1538
+ except (OSError, subprocess.TimeoutExpired):
1539
+ return None
1540
+ if completed.returncode != 0:
1541
+ return None
1542
+ out = completed.stdout.strip()
1543
+ return out or None
1544
+
1545
+
1546
+ def _reap_orphans_with_log() -> None:
1547
+ """Kill orphan openral-graph processes and log the count.
1548
+
1549
+ Reap orphan graph processes from a prior crashed/Ctrl-C'd run.
1550
+ Without this, a stale HAL still holds the robocasa MJCF env +
1551
+ ``/dev/shm/fastrtps_*`` lockfiles, and the new launch surfaces
1552
+ as ``[RTPS_TRANSPORT_SHM Error] Failed init_port
1553
+ fastrtps_port7000`` on the safety_kernel, slam_toolbox,
1554
+ prompt_router, etc.
1555
+ """
1556
+ killed = _kill_orphan_openral_graph_processes()
1557
+ if killed:
1558
+ _console.print(
1559
+ f"[yellow]reaped {killed} orphan openral-graph process(es) from a prior run[/yellow]"
1560
+ )
1561
+
1562
+
1563
+ # Argv substrings that identify a process as belonging to the openral
1564
+ # deploy graph. Each entry must be specific enough that a coincidental
1565
+ # unrelated invocation never matches — the reaper additionally scopes to
1566
+ # the calling user's PIDs (the ``st_uid`` guard) so a shared host is safe.
1567
+ _ORPHAN_GRAPH_NEEDLES: tuple[str, ...] = (
1568
+ "sim_e2e.launch.py",
1569
+ "openral_rskill_ros/runtime_node",
1570
+ "install/lib/openral_hal_",
1571
+ "openral_reasoner_ros/reasoner_node.py",
1572
+ "openral_prompt_router/prompt_router_node.py",
1573
+ "openral_safety_kernel/safety_kernel_node",
1574
+ "openral dashboard",
1575
+ "async_slam_toolbox_node",
1576
+ # Nav2 sub-nodes spawned by openral_nav2_bringup's
1577
+ # IncludeLaunchDescription. The upstream binaries live under
1578
+ # ``/opt/ros/<distro>/lib/nav2_*``; matching the path keeps the
1579
+ # predicate specific (won't catch unrelated ``nav2_*`` python
1580
+ # imports). Without these, a previously-crashed Nav2 graph leaves
1581
+ # zombie controller_server / planner_server / collision_monitor /
1582
+ # bt_navigator / opennav_docking processes alive, and the next
1583
+ # ``openral deploy sim`` hangs in ``Configuring controller_server``
1584
+ # while multiple lifecycle_managers fight over the same nodes.
1585
+ "/lib/nav2_controller/",
1586
+ "/lib/nav2_smoother/",
1587
+ "/lib/nav2_planner/",
1588
+ "/lib/nav2_route/",
1589
+ "/lib/nav2_behaviors/",
1590
+ "/lib/nav2_bt_navigator/",
1591
+ "/lib/nav2_waypoint_follower/",
1592
+ "/lib/nav2_velocity_smoother/",
1593
+ "/lib/nav2_collision_monitor/",
1594
+ "/lib/opennav_docking/",
1595
+ "/lib/nav2_lifecycle_manager/",
1596
+ # TF chain spawned by ``sim_e2e.launch.py``. These were the
1597
+ # silent gap that caused the rldx-rc365 "arm reaches 40 cm high" bug:
1598
+ # a ``static_transform_publisher`` orphaned from a run *before* the
1599
+ # URDF mount-z was zeroed kept publishing the stale ``base_link →
1600
+ # panda_link0 z=0.4`` on the TRANSIENT_LOCAL ``/tf_static`` topic, and
1601
+ # the next launch's correct ``z=0.0`` publisher couldn't override it
1602
+ # (tf2 picks non-deterministically among same-name static frames).
1603
+ # Reaping the renamed static publisher (``static_<base>_to_<root>``)
1604
+ # and the URDF ``robot_state_publisher`` closes that hole. Scoped to
1605
+ # the ``tf2_ros`` / ``robot_state_publisher`` executables under the
1606
+ # calling user so we never touch an unrelated TF graph.
1607
+ "/lib/tf2_ros/static_transform_publisher",
1608
+ "/lib/robot_state_publisher/robot_state_publisher",
1609
+ # rldx out-of-process sidecar (ADR auto-spawn). It runs in its OWN
1610
+ # session (``start_new_session=True`` in ``openral_sim.policies.rldx``)
1611
+ # so the launch group's SIGINT never reaches it; if the runtime node
1612
+ # dies before its adapter ``close()`` runs, the sidecar keeps the
1613
+ # GR00T/RLDX weights resident and starves the GPU (~6.5 GiB) of the
1614
+ # next run. The cache dir is openral-specific, so this is unambiguous.
1615
+ "/.cache/openral/rldx-sidecar/",
1616
+ # Perception / critic graph nodes spawned by ``sim_e2e.launch.py``. These
1617
+ # were absent from the sweep, so under a heavy graph whose graceful
1618
+ # shutdown doesn't finish within ``grace_s`` they orphaned (the reward
1619
+ # monitor holds the sidecar; the detector holds its model). Scoped to the
1620
+ # in-tree node entry points so we never touch an unrelated process.
1621
+ "openral_perception_ros/reward_monitor_node.py",
1622
+ "openral_perception_ros/ros_image_detector_node.py",
1623
+ "openral_reasoner_ros/critic_producer_node.py",
1624
+ )
1625
+
1626
+
1627
+ def _cmdline_is_openral_graph_process(cmdline: str) -> bool:
1628
+ """Return True when ``cmdline`` matches an openral deploy-graph process.
1629
+
1630
+ Pure predicate over a space-joined ``/proc/<pid>/cmdline`` string so
1631
+ the needle set (:data:`_ORPHAN_GRAPH_NEEDLES`) is unit-testable
1632
+ without spawning real processes.
1633
+ """
1634
+ return any(needle in cmdline for needle in _ORPHAN_GRAPH_NEEDLES)
1635
+
1636
+
1637
+ def _kill_orphan_openral_graph_processes() -> int:
1638
+ """SIGKILL orphaned openral-graph processes from a prior ``openral deploy sim``.
1639
+
1640
+ A graceful ``Ctrl-C`` doesn't always propagate through ``ros2
1641
+ launch`` to every child — under load, the launch dispatcher
1642
+ exits before the HAL has finished tearing down its in-process
1643
+ robocasa env, and the HAL python process keeps running. On the
1644
+ next ``openral deploy sim``:
1645
+
1646
+ * the prior dashboard still holds ``127.0.0.1:4318`` →
1647
+ ``[Errno 98] address already in use`` on the new dashboard;
1648
+ * the prior HAL still holds ``/dev/shm/fastrtps_*`` lockfiles →
1649
+ ``[RTPS_TRANSPORT_SHM Error] Failed init_port fastrtps_port7000``
1650
+ on the new safety_kernel/slam_toolbox/prompt_router;
1651
+ * the prior HAL still holds the MJCF env handle → the new HAL's
1652
+ configure step hangs waiting for the robocasa loader.
1653
+
1654
+ Match orphans by argv signature so we never reach into other
1655
+ users' processes or unrelated python invocations. Best-effort:
1656
+ SIGKILL failures (permission denied, race) are silently
1657
+ skipped. Returns the count of processes killed for logging.
1658
+ """
1659
+ proc_root = Path("/proc")
1660
+ if not proc_root.is_dir():
1661
+ # No procfs (macOS dev hosts) — nothing to sweep. Deployment
1662
+ # targets are Linux; on Darwin the reap is a no-op rather than a
1663
+ # FileNotFoundError crash inside _run_launch.
1664
+ return 0
1665
+ me = os.getuid()
1666
+ killed = 0
1667
+ for entry in proc_root.iterdir():
1668
+ if not entry.name.isdigit():
1669
+ continue
1670
+ pid = int(entry.name)
1671
+ if pid == os.getpid():
1672
+ continue
1673
+ try:
1674
+ st = entry.stat()
1675
+ except (FileNotFoundError, PermissionError):
1676
+ continue
1677
+ if st.st_uid != me:
1678
+ continue # other users' processes are not ours to kill
1679
+ try:
1680
+ cmdline = (
1681
+ (entry / "cmdline")
1682
+ .read_bytes()
1683
+ .replace(b"\x00", b" ")
1684
+ .decode(
1685
+ errors="replace",
1686
+ )
1687
+ )
1688
+ except (FileNotFoundError, PermissionError):
1689
+ continue
1690
+ if not _cmdline_is_openral_graph_process(cmdline):
1691
+ continue
1692
+ with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
1693
+ os.kill(pid, signal.SIGKILL)
1694
+ killed += 1
1695
+ return killed
1696
+
1697
+
1698
+ def _terminate_launch_group(proc: subprocess.Popen[bytes], *, grace_s: float = 12.0) -> None:
1699
+ """SIGINT the launch's session, then SIGKILL any straggler after a grace.
1700
+
1701
+ ``proc`` was spawned with ``start_new_session=True`` so its PGID
1702
+ equals its PID and names the whole launch tree. SIGINT (not SIGTERM)
1703
+ is what ``ros2 launch`` translates into a graceful lifecycle
1704
+ shutdown; we give it ``grace_s`` to drain, then SIGKILL the group so
1705
+ no node — or the launch-spawned static_transform_publisher /
1706
+ robot_state_publisher — survives to orphan and poison the next run.
1707
+ """
1708
+ if proc.poll() is not None:
1709
+ return
1710
+ with contextlib.suppress(ProcessLookupError, OSError):
1711
+ os.killpg(proc.pid, signal.SIGINT)
1712
+ with contextlib.suppress(subprocess.TimeoutExpired):
1713
+ proc.wait(timeout=grace_s)
1714
+ return
1715
+ with contextlib.suppress(ProcessLookupError, OSError):
1716
+ os.killpg(proc.pid, signal.SIGKILL)
1717
+ with contextlib.suppress(subprocess.TimeoutExpired):
1718
+ proc.wait(timeout=5.0)
1719
+
1720
+
1721
+ def _run_launch(argv: list[str], env: dict[str, str], *, grace_s: float = 12.0) -> int:
1722
+ """Run ``ros2 launch`` in its own session, reaping the whole tree on exit.
1723
+
1724
+ The legacy ``subprocess.run(argv)`` left the launch a sibling in the
1725
+ CLI's process group: a ``kill -INT`` of this CLI (or any non-terminal
1726
+ exit) signalled only the CLI, and ``ros2 launch`` plus every node it
1727
+ spawned — the HAL (holding the MuJoCo render context, ~1.2 GiB GPU),
1728
+ robot_state_publisher, the ``static_<base>_to_<root>`` static TF
1729
+ publisher — were reparented to init and kept running. Those orphans
1730
+ accumulated across dev iterations and poisoned the TRANSIENT_LOCAL
1731
+ ``/tf_static`` topic (see ``_kill_orphan_openral_graph_processes``).
1732
+
1733
+ Teardown runs in three escalating stages so nothing survives,
1734
+ whatever the process-group topology:
1735
+
1736
+ 1. Forward SIGINT/SIGTERM to the launch's session so ``ros2 launch``
1737
+ runs its graceful shutdown (each node's ``on_shutdown`` fires; the
1738
+ HAL releases its MJCF env; the skill adapter's ``close()``
1739
+ terminates the rldx sidecar).
1740
+ 2. After ``grace_s`` escalate to SIGKILL on the launch's process
1741
+ group (``_terminate_launch_group``).
1742
+ 3. Sweep by argv signature (``_kill_orphan_openral_graph_processes``).
1743
+ This is the bulletproof backstop: ``ros2 launch`` spawns its nodes
1744
+ in their OWN process groups (and the rldx sidecar in its own
1745
+ session), so neither ``killpg`` reaches them directly — under a
1746
+ heavy graph the graceful shutdown often doesn't finish before the
1747
+ launch exits, and stage 1+2 alone leak nodes. The signature sweep
1748
+ matches every graph process regardless of parentage.
1749
+
1750
+ Returns the launch process's exit code (0 if it exited via signal
1751
+ with no recorded returncode).
1752
+ """
1753
+ proc = subprocess.Popen(argv, env=env, start_new_session=True)
1754
+
1755
+ def _forward(_signum: int, _frame: object) -> None:
1756
+ with contextlib.suppress(ProcessLookupError, OSError):
1757
+ os.killpg(proc.pid, signal.SIGINT)
1758
+
1759
+ prev_int = signal.signal(signal.SIGINT, _forward)
1760
+ prev_term = signal.signal(signal.SIGTERM, _forward)
1761
+ try:
1762
+ proc.wait()
1763
+ finally:
1764
+ signal.signal(signal.SIGINT, prev_int)
1765
+ signal.signal(signal.SIGTERM, prev_term)
1766
+ _terminate_launch_group(proc, grace_s=grace_s)
1767
+ # ``ros2 launch`` spawns its nodes in their OWN process groups, so
1768
+ # ``killpg`` on the launch's group only reaps them via ros2
1769
+ # launch's *graceful* shutdown — which, under a heavy graph
1770
+ # (nav2 + slam + sidecar), routinely does not finish before the
1771
+ # launch process itself exits, leaving the nodes (and the
1772
+ # own-session rldx sidecar) orphaned. Sweep by argv signature as
1773
+ # the bulletproof backstop: it matches every graph process
1774
+ # regardless of parentage or process group. Safe because two
1775
+ # deploy graphs can't coexist (they collide on DDS/ports), so on
1776
+ # exit the only matching processes are this run's survivors.
1777
+ _kill_orphan_openral_graph_processes()
1778
+ return proc.returncode if proc.returncode is not None else 0
1779
+
1780
+
1781
+ def _apply_rmw_default(env: dict[str, str]) -> None:
1782
+ """Clean stale Fast-DDS SHM lockfiles before spawning ``ros2 launch``.
1783
+
1784
+ ROS 2 Jazzy defaults to Fast-DDS, whose per-participant SHM
1785
+ files live under ``/dev/shm/fastrtps_*``. When a prior launch
1786
+ exited uncleanly (Ctrl-C, OOM kill, ``pkill -9``), those
1787
+ lockfiles persist, and the next Fast-DDS participant fails to
1788
+ bind with ``[RTPS_TRANSPORT_SHM Error] Failed init_port
1789
+ fastrtps_port7000: open_and_lock_file failed`` — breaking every
1790
+ subsequent ``openral deploy sim`` until the operator manually
1791
+ cleans them. CLAUDE.md §2 names Cyclone as the OpenRAL default,
1792
+ but the launch_ros lifecycle_event_manager in Jazzy has a
1793
+ sharp edge with Cyclone (deserialisation race on the
1794
+ auto-CONFIGURE → ACTIVATE chain) that takes down the
1795
+ prompt_router; until that's resolved we stay on Fast-DDS and
1796
+ aggressively clean its stale state.
1797
+
1798
+ Best-effort: only files owned by the calling user are
1799
+ unlinked — other users' SHM segments are silently skipped.
1800
+ Operators that explicitly opt into Cyclone or Zenoh via
1801
+ ``RMW_IMPLEMENTATION`` keep theirs untouched.
1802
+ """
1803
+ if "rmw_cyclonedds" in env.get("RMW_IMPLEMENTATION", ""):
1804
+ return # operator opted into Cyclone; nothing to clean
1805
+ if "rmw_zenoh" in env.get("RMW_IMPLEMENTATION", ""):
1806
+ return
1807
+ _clean_stale_fastrtps_shm()
1808
+
1809
+
1810
+ def _clean_stale_fastrtps_shm() -> None:
1811
+ """Best-effort: remove stale Fast-DDS SHM lock files in ``/dev/shm``.
1812
+
1813
+ Fast-DDS lock files are owned by the user that created them — we
1814
+ silently skip anything we can't unlink (another user's file) so
1815
+ this never escalates to ``sudo``-required cleanup. Cyclone-DDS
1816
+ deployments never call this path. Only fires when the operator
1817
+ explicitly opts back into Fast-DDS via ``RMW_IMPLEMENTATION``.
1818
+ """
1819
+ shm = Path("/dev/shm")
1820
+ if not shm.is_dir():
1821
+ return
1822
+ for entry in shm.iterdir():
1823
+ if not entry.name.startswith("fastrtps_"):
1824
+ continue
1825
+ with contextlib.suppress(OSError, PermissionError):
1826
+ entry.unlink()
1827
+
1828
+
1829
+ def _required_ros2_packages(invocation: LaunchInvocation) -> list[str]:
1830
+ """Build the package-list the preflight discovery check must validate.
1831
+
1832
+ Pulled out of :func:`deploy_sim_command` for line-count hygiene
1833
+ and so future opt-in bringup wrappers extend a single list.
1834
+ """
1835
+ pkgs = ["openral_rskill_ros", invocation.hal.package]
1836
+ if invocation.enable_slam:
1837
+ pkgs.append("openral_slam_bringup")
1838
+ if invocation.enable_nav2:
1839
+ pkgs.append("openral_nav2_bringup")
1840
+ return pkgs
1841
+
1842
+
1843
+ def assert_ros2_packages_discoverable(
1844
+ packages: Iterable[str],
1845
+ *,
1846
+ prefix_lookup: Callable[[str], str | None] = _ros2_pkg_prefix,
1847
+ ) -> None:
1848
+ """Raise ``ROSConfigError`` listing every ``pkg`` ``ros2`` cannot find.
1849
+
1850
+ Catches the most common operator failure for ``openral deploy sim``:
1851
+ ``ros2`` itself is on PATH (so the system overlay is sourced) but
1852
+ the OpenRAL workspace overlay (``install/setup.bash``) is not, so
1853
+ ``ros2 launch`` can't find ``openral_rskill_ros`` or the per-robot
1854
+ ``openral_hal_<X>`` package. The same error fires for a stale build
1855
+ that simply hasn't included the requested HAL package yet.
1856
+
1857
+ ``prefix_lookup`` is injectable so unit tests can drive the path
1858
+ with a deterministic fake without shelling out (CLAUDE.md §1.11
1859
+ process-boundary fake).
1860
+ """
1861
+ missing = [pkg for pkg in packages if prefix_lookup(pkg) is None]
1862
+ if not missing:
1863
+ return
1864
+ quoted = ", ".join(repr(p) for p in missing)
1865
+ raise ROSConfigError(
1866
+ f"ros2 cannot find ROS package(s): {quoted}. The OpenRAL workspace "
1867
+ "overlay is not sourced (or the build is stale). From the repo root, "
1868
+ "run:\n"
1869
+ " just ros2-build && source install/setup.bash\n"
1870
+ "then re-run ``openral deploy sim``. Note: the package is named "
1871
+ "``openral_rskill_ros`` (not ``rskill``) — the Python sub-package "
1872
+ "under ``python/rskill/`` is a different layer."
1873
+ )
1874
+
1875
+
1876
+ def _preflight_palette_deps( # noqa: PLR0912, PLR0915 # reason: linear flow — split would obscure the prompt → install → re-probe contract
1877
+ *,
1878
+ repo_root: Path,
1879
+ robot_yaml: Path,
1880
+ commercial_deployment: bool = False,
1881
+ ) -> None:
1882
+ """Prompt to install missing extras before the reasoner palette empties.
1883
+
1884
+ Mirrors :meth:`ReasonerNode._maybe_seed_palette_from_search_paths`:
1885
+ loads ``<repo_root>/rskills/*/rskill.yaml``, builds the
1886
+ capability-filtered :class:`~openral_reasoner.palette.ToolPalette`
1887
+ against the robot's :class:`~openral_core.RobotCapabilities`, then
1888
+ probes each capability-matching manifest's ``model_family`` for
1889
+ importability. Surfaces missing extras *before* the launch
1890
+ instead of letting the reasoner silently drop them at
1891
+ ``on_configure`` time (the operator only finds out via
1892
+ ``palette_empty`` ticks once everything is up).
1893
+
1894
+ This is ADVISORY, not a gate. The reasoner ALREADY drops
1895
+ unimportable rSkills at ``on_configure``
1896
+ (:func:`openral_sim.policy_deps.filter_importable_manifests`) and
1897
+ runs the importable remainder. The palette is robot-WIDE — a single
1898
+ franka config matches six model families (act / molmoact2 / pi05 /
1899
+ rldx / smolvla / xvla), so a partially-installed venv is the common
1900
+ case and demanding every family's extras to run ONE skill is the
1901
+ wrong contract. So we warn-and-drop, and hard-fail only when the
1902
+ palette would be left empty (nothing dispatchable).
1903
+
1904
+ Behaviour (when ≥1 matching skill is blocked on missing extras):
1905
+
1906
+ * default / ``OPENRAL_AUTO_INSTALL_DEPS=1`` → install the union of
1907
+ missing groups via ``just sync --all-packages --group …``
1908
+ (cwd=repo_root), re-probe, and continue. Same env var honoured by
1909
+ :mod:`openral_sim._assets` / :mod:`openral_sim._deps`. A non-zero
1910
+ ``just sync`` is a real failure → ``typer.Exit``.
1911
+ * ``OPENRAL_AUTO_INSTALL_DEPS=0`` on a TTY → ``typer.confirm`` the
1912
+ same install; on yes install+re-probe; on no → drop blocked skills.
1913
+ * ``OPENRAL_AUTO_INSTALL_DEPS=0`` non-TTY → drop the blocked skills
1914
+ and proceed, printing the install command as a hint.
1915
+ * In every "proceed" path above: if EVERY matching skill is blocked
1916
+ (palette would be empty) → print the install command and
1917
+ ``typer.Exit(1)`` instead, since the graph could dispatch nothing.
1918
+ * No capability-matching skills are blocked → silent return.
1919
+ """
1920
+ from openral_core import RobotDescription, RSkillManifest
1921
+ from openral_reasoner.palette import build_tool_palette
1922
+ from openral_sim.policy_deps import (
1923
+ can_import_policy_family,
1924
+ model_family_install_groups,
1925
+ model_family_install_hint,
1926
+ )
1927
+
1928
+ rskills_dir = repo_root / "rskills"
1929
+ manifest_paths = sorted(rskills_dir.glob("*/rskill.yaml"))
1930
+ if not manifest_paths:
1931
+ return
1932
+
1933
+ try:
1934
+ description = RobotDescription.from_yaml(str(robot_yaml))
1935
+ except (OSError, ValueError):
1936
+ # Robot.yaml will fail loudly downstream — don't double-report here.
1937
+ return
1938
+
1939
+ manifests: list[RSkillManifest] = []
1940
+ for path in manifest_paths:
1941
+ try:
1942
+ manifests.append(RSkillManifest.from_yaml(str(path)))
1943
+ except (OSError, ValueError):
1944
+ # Same "skip unloadable" behaviour as the reasoner seed.
1945
+ continue
1946
+
1947
+ matching = build_tool_palette(
1948
+ installed_skills=manifests,
1949
+ robot_capabilities=description.capabilities,
1950
+ commercial_deployment=commercial_deployment,
1951
+ )
1952
+ matching_ids = matching.execute_rskill_ids
1953
+ if not matching_ids:
1954
+ # Palette would be empty for capability / role / license reasons,
1955
+ # not deps. Reasoner already logs this clearly at on_configure.
1956
+ return
1957
+
1958
+ blocked: list[tuple[str, str]] = [] # (manifest_name, model_family)
1959
+ install_groups: set[str] = set()
1960
+ for m in manifests:
1961
+ if m.name not in matching_ids:
1962
+ continue
1963
+ family = getattr(m, "model_family", None) or ""
1964
+ ok, _ = can_import_policy_family(family)
1965
+ if ok:
1966
+ continue
1967
+ blocked.append((m.name, family))
1968
+ install_groups.update(model_family_install_groups(family))
1969
+
1970
+ if not blocked:
1971
+ return
1972
+
1973
+ _console.print()
1974
+ _console.print(
1975
+ f"[yellow]preflight:[/yellow] {len(blocked)} of "
1976
+ f"{len(matching_ids)} capability-matched rSkill(s) are missing "
1977
+ "Python extras:"
1978
+ )
1979
+ for name, family in blocked:
1980
+ _console.print(f" • {name} (model_family={family!r})")
1981
+ _console.print(f" {model_family_install_hint(family)}")
1982
+
1983
+ install_cmd: list[str] | None = None
1984
+ if install_groups:
1985
+ groups_argv: list[str] = []
1986
+ for g in sorted(install_groups):
1987
+ groups_argv.extend(["--group", g])
1988
+ # Route through ``just sync`` (not bare ``uv sync``) for two
1989
+ # reasons:
1990
+ # 1. ``--all-packages`` is REQUIRED so the workspace members
1991
+ # (openral-core, openral-cli, ...) survive the install.
1992
+ # ``uv sync --group <X>`` without ``--all-packages``
1993
+ # uninstalls every workspace member — the next ROS launch
1994
+ # then fails with ``No module named 'openral_core'``
1995
+ # (the exact symptom the preflight is meant to prevent).
1996
+ # 2. The libero/robocasa groups pull in ``hf-libero==0.1.3``,
1997
+ # whose sdist installs both modern and legacy uninstall
1998
+ # metadata. The ``just sync`` recipe repairs that
1999
+ # before+after via ``scripts/repair_hf_libero_install.py``
2000
+ # so the next ``uv sync --all-packages`` doesn't bail out
2001
+ # with ``Unable to uninstall hf-libero==0.1.3``.
2002
+ # 3. ``--inexact`` makes the install ADDITIVE. Without it, ``uv
2003
+ # sync --group <X>`` is exact-match on the dependency-group set
2004
+ # and uninstalls every package not in group ``X`` — including
2005
+ # run-critical packages from sibling groups: the OmDet-Turbo
2006
+ # detector's ``timm`` (group ``omdet``), ``robosuite`` (group
2007
+ # ``robocasa``), rldx's ``pyzmq``/``msgpack``. The observed
2008
+ # failure: installing the ``rldx`` palette extras wiped ``timm``,
2009
+ # so the detector ImportError'd on every frame and
2010
+ # ``/openral/perception/objects`` stayed empty (issue #12). The
2011
+ # robocasa AUTO_INSTALL plan already uses ``--inexact`` for this
2012
+ # exact reason (openral_sim._deps._robocasa_kitchen_plan).
2013
+ install_cmd = ["just", "sync", "--all-packages", "--inexact", *groups_argv]
2014
+
2015
+ # Install by default; set OPENRAL_AUTO_INSTALL_DEPS=0 to prompt on a
2016
+ # TTY or skip on non-TTY (honoured by openral_sim._assets / _deps).
2017
+ auto_install = os.environ.get("OPENRAL_AUTO_INSTALL_DEPS", "1") == "1"
2018
+ is_interactive = sys.stdin.isatty() and sys.stdout.isatty()
2019
+ attempt_install = install_cmd is not None and (
2020
+ auto_install
2021
+ or (
2022
+ is_interactive
2023
+ and typer.confirm(
2024
+ f"Install missing extras now with `{shlex.join(install_cmd)}`?",
2025
+ default=True,
2026
+ )
2027
+ )
2028
+ )
2029
+
2030
+ if attempt_install:
2031
+ assert install_cmd is not None # narrowed by attempt_install guard
2032
+ # Propagate the operator's install-consent to the launched graph. The
2033
+ # scene backend's on_configure asset/dep install (openral_sim._assets,
2034
+ # gated on OPENRAL_AUTO_INSTALL_DEPS) must then proceed WITHOUT a second
2035
+ # prompt — otherwise the graph blocks before the MuJoCo viewer opens and
2036
+ # the operator is forced to set the env var by hand. run_launch_invocation
2037
+ # builds the launch env via os.environ.copy(), so setting it here carries
2038
+ # the single "yes, install" answer to every downstream group/asset install,
2039
+ # not just this rSkill-extras step.
2040
+ os.environ["OPENRAL_AUTO_INSTALL_DEPS"] = "1"
2041
+ _console.print(f"[cyan]running:[/cyan] {shlex.join(install_cmd)}")
2042
+ # cwd=repo_root so ``just`` resolves the workspace ``Justfile``
2043
+ # regardless of where the user invoked ``openral deploy sim`` from.
2044
+ completed = subprocess.run(install_cmd, check=False, cwd=str(repo_root))
2045
+ if completed.returncode != 0:
2046
+ # The operator explicitly asked to install (env var / confirm)
2047
+ # and it failed — surface it, don't silently drop and boot a
2048
+ # degraded graph.
2049
+ _console.print(
2050
+ f"[red]uv sync failed (exit {completed.returncode}).[/red] "
2051
+ "Fix the install and re-run ``openral deploy sim``."
2052
+ )
2053
+ raise typer.Exit(code=completed.returncode)
2054
+ # Flush importer caches so the freshly-installed packages are
2055
+ # discoverable on the re-probe (the .venv is the same one this
2056
+ # process runs from; new files on disk need an invalidate to
2057
+ # show up via PathFinder), then recompute the blocked set.
2058
+ importlib.invalidate_caches()
2059
+ blocked = [(n, f) for n, f in blocked if not can_import_policy_family(f)[0]]
2060
+ if not blocked:
2061
+ _console.print("[green]preflight:[/green] extras installed; continuing launch.")
2062
+ return
2063
+ # Partial success — fall through to the drop/empty decision with
2064
+ # the reduced ``blocked`` set.
2065
+
2066
+ # We did NOT fully resolve the missing extras (declined / non-TTY /
2067
+ # no install command / partial install). Preflight is advisory: drop
2068
+ # the blocked skills and proceed so the reasoner runs the importable
2069
+ # remainder — UNLESS that remainder is empty, in which case the graph
2070
+ # could dispatch nothing and we fail fast with the install command.
2071
+ kept_count = len(matching_ids) - len(blocked)
2072
+ if kept_count <= 0:
2073
+ _console.print()
2074
+ if install_cmd is not None:
2075
+ _console.print(
2076
+ "[red]preflight failed:[/red] every capability-matched rSkill is "
2077
+ "blocked on missing extras — the reasoner palette would be empty. "
2078
+ f"Install them:\n {shlex.join(install_cmd)}"
2079
+ )
2080
+ else:
2081
+ _console.print(
2082
+ "[red]preflight failed:[/red] every capability-matched rSkill is "
2083
+ "blocked — see per-skill hints above."
2084
+ )
2085
+ raise typer.Exit(code=1)
2086
+
2087
+ _console.print()
2088
+ _console.print(
2089
+ f"[yellow]preflight:[/yellow] proceeding — {len(blocked)} skill(s) will be "
2090
+ f"dropped from the reasoner palette; {kept_count} remain dispatchable."
2091
+ )
2092
+ if install_cmd is not None:
2093
+ _console.print(f" to enable the dropped skill(s): {shlex.join(install_cmd)}")
2094
+
2095
+
2096
+ def deploy_sim_command(
2097
+ config: Path = typer.Option( # reason: typer Option idiom
2098
+ ...,
2099
+ "--config",
2100
+ "-c",
2101
+ exists=True,
2102
+ readable=True,
2103
+ dir_okay=False,
2104
+ help=(
2105
+ "Path to a DeployScene YAML (scenes/deploy/, scene + optional "
2106
+ "robot, no task). Strict: SimScene / BenchmarkScene YAMLs are "
2107
+ "rejected with a redirect to `openral sim run` / "
2108
+ "`openral benchmark scene`."
2109
+ ),
2110
+ ),
2111
+ robot: str | None = typer.Option(
2112
+ None,
2113
+ "--robot",
2114
+ help=(
2115
+ "Override the DeployScene's ``robot_id``. Required when the YAML omits ``robot_id``."
2116
+ ),
2117
+ ),
2118
+ dashboard_port: int = typer.Option(
2119
+ 4318,
2120
+ "--dashboard-port",
2121
+ help="OTLP/HTTP port passed through to the launch's dashboard child.",
2122
+ ),
2123
+ reset_to_pose_service: str | None = typer.Option(
2124
+ None,
2125
+ "--reset-to-pose-service",
2126
+ help=(
2127
+ "Override the HAL ``reset_to_pose`` service path. Defaults to "
2128
+ "``/openral/<robot_id>/reset_to_pose``."
2129
+ ),
2130
+ ),
2131
+ approach_skill_id: str | None = typer.Option(
2132
+ None,
2133
+ "--approach-skill-id",
2134
+ help=(
2135
+ "MoveIt approach rSkill URI (e.g. "
2136
+ "``rskills/rskill-moveit-joints``). When set, the runner plans a "
2137
+ "collision-free MoveGroup motion to each skill's starting_pose "
2138
+ "instead of the teleport snap (needs a running move_group). Empty "
2139
+ "keeps the legacy ResetToPose snap."
2140
+ ),
2141
+ ),
2142
+ dataset_out: str | None = typer.Option(
2143
+ None,
2144
+ "--dataset-out",
2145
+ help=(
2146
+ "record the deploy session (proprio + action + camera "
2147
+ "frames + episode markers) to this rosbag2 mcap path. Convert to a "
2148
+ "LeRobotDataset v3 offline with `openral dataset from-bag`. Empty "
2149
+ "disables recording."
2150
+ ),
2151
+ ),
2152
+ dataset_repo_id: str | None = typer.Option(
2153
+ None,
2154
+ "--dataset-repo-id",
2155
+ help="repo_id for the recorded dataset (default openral/dataset-<robot>).",
2156
+ ),
2157
+ dataset_license: str | None = typer.Option(
2158
+ None,
2159
+ "--dataset-license",
2160
+ help="SPDX license carried into `openral dataset from-bag` (default CC-BY-4.0).",
2161
+ ),
2162
+ hal: list[str] = typer.Option( # reason: typer Option idiom
2163
+ None,
2164
+ "--hal",
2165
+ help=(
2166
+ "Per-robot HAL parameter override, ``key=value`` (repeatable). "
2167
+ "Value is parsed as JSON when possible (so ``--hal "
2168
+ "viewer_enabled=false`` works); otherwise treated as a string. "
2169
+ "Overrides the per-robot defaults in ``_ROBOT_HAL_REGISTRY``."
2170
+ ),
2171
+ ),
2172
+ enable_slam: bool | None = typer.Option(
2173
+ None,
2174
+ "--enable-slam/--no-enable-slam",
2175
+ help=(
2176
+ "bring up slam_toolbox as a Reasoner-managed "
2177
+ "background service. **Auto by default**: enabled when the "
2178
+ "robot's manifest declares ``capabilities.has_lidar: true``. "
2179
+ "Pass ``--enable-slam`` / ``--no-enable-slam`` to override the "
2180
+ "manifest. The launcher auto-transitions slam_toolbox to "
2181
+ "INACTIVE; the Reasoner promotes to ACTIVE via "
2182
+ 'LifecycleTransitionTool(node="/openral_slam_toolbox"). '
2183
+ "Requires ros-${ROS_DISTRO}-slam-toolbox apt-installed and "
2184
+ "the openral_slam_bringup package colcon-built in the "
2185
+ "workspace."
2186
+ ),
2187
+ ),
2188
+ enable_nav2: bool | None = typer.Option(
2189
+ None,
2190
+ "--enable-nav2/--no-enable-nav2",
2191
+ help=(
2192
+ "bring up the Nav2 navigation stack so the "
2193
+ "``OpenRAL/rskill-nav2-navigate-to-pose`` wrapped-action "
2194
+ "rSkill has a ``/navigate_to_pose`` server to dispatch to. "
2195
+ "**Auto by default**: tracks ``--enable-slam`` (lidar-"
2196
+ "equipped robots need a planner to consume the map). "
2197
+ "Requires ros-${ROS_DISTRO}-nav2-bringup apt-installed "
2198
+ "and the openral_nav2_bringup package colcon-built."
2199
+ ),
2200
+ ),
2201
+ object_detector_locator: list[str] | None = typer.Option(
2202
+ None,
2203
+ "--object-detector-locator",
2204
+ help=(
2205
+ "on-demand open-vocab locator to bring up alongside the "
2206
+ "continuous detector (repeatable). A manifest path or a short alias "
2207
+ "(e.g. 'omdet-turbo-locator', 'locateanything-3b-nf4'). Each becomes a "
2208
+ "namespaced locate_in_view node the reasoner picks via the tool's "
2209
+ "'detector' field. Default = omdet-turbo-locator when the detector is "
2210
+ "on and the omdet deps are present; LocateAnything is opt-in (NVIDIA "
2211
+ "non-commercial, 5 GB VRAM, needs the sidecar venv)."
2212
+ ),
2213
+ ),
2214
+ enable_octomap: bool | None = typer.Option(
2215
+ None,
2216
+ "--enable-octomap/--no-enable-octomap",
2217
+ help=(
2218
+ "bring up the world-collision perception leg: "
2219
+ "octomap_server (3-D OcTree from the HAL's depth PointCloud2) "
2220
+ "+ openral_octomap_bridge (octree → /openral/world_voxels) + "
2221
+ "the C++ safety kernel's capsule-vs-voxel check. **Auto by "
2222
+ "default**: enabled when the robot manifest declares a depth "
2223
+ "SensorSpec. Requires ros-${ROS_DISTRO}-octomap-server "
2224
+ "apt-installed and the openral_octomap_bridge package "
2225
+ "colcon-built."
2226
+ ),
2227
+ ),
2228
+ enable_octomap_kernel_check: bool | None = typer.Option(
2229
+ None,
2230
+ "--enable-octomap-kernel-check/--no-enable-octomap-kernel-check",
2231
+ help=(
2232
+ "When --no-enable-octomap-kernel-check, the octomap "
2233
+ "perception leg still publishes /openral/world_voxels (so the "
2234
+ "world-state object-lift works) but the C++ safety kernel's "
2235
+ "capsule-vs-voxel check stays OFF (its --no-enable-octomap posture: "
2236
+ "envelope + self-collision only). Use with --enable-octomap to let "
2237
+ "perception use the world map without the dense-scene false-positive "
2238
+ "E-stop. Unset = the scene's runtime block, else on (bundled "
2239
+ "world-collision-leg behaviour)."
2240
+ ),
2241
+ ),
2242
+ enable_object_detector: bool | None = typer.Option(
2243
+ None,
2244
+ "--object-detector/--no-object-detector",
2245
+ help=(
2246
+ "bring up the ROS-Image object detector "
2247
+ "(openral_perception_ros/ros_image_detector_node): publishes "
2248
+ "ObjectsMetadata to /openral/perception/objects, which the "
2249
+ "world-state node's object-lift raises into /openral/world_voxels. "
2250
+ "**On by default** (unset = the scene's runtime block, else on). "
2251
+ "The default backend is the open-vocabulary "
2252
+ "omdet-turbo-indoor continuous detector (falls back to the in-tree "
2253
+ "RT-DETR COCO ONNX when the omdet deps are absent). Pass "
2254
+ "--no-object-detector to turn the leg off. Requires the "
2255
+ "openral_perception_ros package colcon-built."
2256
+ ),
2257
+ ),
2258
+ object_detector_onnx: Path | None = typer.Option(
2259
+ None,
2260
+ "--object-detector-onnx",
2261
+ help=(
2262
+ "path to the RT-DETR ONNX weights for the legacy / "
2263
+ "fallback detector path. Defaults to the in-tree "
2264
+ "rskills/rtdetr-coco-r18/model.onnx. Passing a path explicitly "
2265
+ "selects the fixed-label RT-DETR backend over the omdet default."
2266
+ ),
2267
+ ),
2268
+ object_detector_manifest: str | None = typer.Option(
2269
+ None,
2270
+ "--object-detector-manifest",
2271
+ help=(
2272
+ "path to a kind:detector rSkill manifest "
2273
+ "(e.g. rskills/locateanything-3b-nf4/rskill.yaml). When set, the "
2274
+ "detector node is manifest-driven: runtime:pytorch brings up the "
2275
+ "open-vocabulary LocateAnything VLM sidecar; runtime:onnx uses "
2276
+ "RT-DETR. A manifest path auto-enables the detector leg (no ONNX "
2277
+ "file needed). The VLM sidecar needs an isolated transformers==4.57.1 "
2278
+ "venv (OPENRAL_LOCATEANYTHING_SIDECAR_VENV) + OPENRAL_ALLOW_NONCOMMERCIAL=1."
2279
+ ),
2280
+ ),
2281
+ object_detector_query: str | None = typer.Option(
2282
+ None,
2283
+ "--object-detector-query",
2284
+ help=(
2285
+ "initial open-vocabulary query for a VLM "
2286
+ "detector (e.g. 'red mug'). Empty = the manifest's detector.labels "
2287
+ "default. Retarget live by publishing a std_msgs/String to "
2288
+ "/openral/perception/detector_query."
2289
+ ),
2290
+ ),
2291
+ enable_reward_monitor: bool | None = typer.Option(
2292
+ None,
2293
+ "--enable-reward-monitor/--no-enable-reward-monitor",
2294
+ help=(
2295
+ "bring up the Robometer reward monitor "
2296
+ "(openral_perception_ros/reward_monitor_node) PARALLEL to the VLA: it "
2297
+ "buffers the agentview RGB stream and serves "
2298
+ "/openral/perception/query_task_progress, and the reasoner is told "
2299
+ "task_progress_available=True so its LLM may poll per-frame "
2300
+ "progress/success whenever it sees fit. Advisory-only. Unset = the "
2301
+ "scene's runtime block, else off. "
2302
+ "Needs the openral_perception_ros package colcon-built and "
2303
+ "Robometer/TOPReward deps in the current env; "
2304
+ "co-resident with a VLA wants a small NF4 VLA on an 8 GB GPU (~3.3 GB)."
2305
+ ),
2306
+ ),
2307
+ enable_critic: bool | None = typer.Option(
2308
+ None,
2309
+ "--enable-critic/--no-enable-critic",
2310
+ help=(
2311
+ "bring up the Tier-C critic producer "
2312
+ "(openral_reasoner_ros/critic_producer_node). It watches the generic "
2313
+ "/openral/critic/score topic that reward models publish (Robometer, a "
2314
+ "future SARM, success classifiers) and emits a Tier-C FailureTrigger on "
2315
+ "/openral/failure/critic when a critic stalls — the reasoner already maps "
2316
+ "that to a forced Tier-C tick (replanning). Advisory-only. Unset = "
2317
+ "the scene's runtime block, else off."
2318
+ ),
2319
+ ),
2320
+ reward_monitor_manifest: str | None = typer.Option(
2321
+ None,
2322
+ "--reward-monitor-manifest",
2323
+ help=(
2324
+ "path to a kind:reward rSkill manifest. Empty defaults to "
2325
+ "the in-tree rskills/robometer-4b/rskill.yaml. weights_uri may be "
2326
+ "hf://org/repo or local:///abs/path (a pre-quantized NF4 checkpoint "
2327
+ "loaded directly as 4-bit). Ignored unless --enable-reward-monitor."
2328
+ ),
2329
+ ),
2330
+ reward_monitor_task: str | None = typer.Option(
2331
+ None,
2332
+ "--reward-monitor-task",
2333
+ help=(
2334
+ "default task instruction the reward monitor scores when a "
2335
+ "query leaves task empty. The reasoner normally passes the active task "
2336
+ "per query. Ignored unless --enable-reward-monitor."
2337
+ ),
2338
+ ),
2339
+ spatial_memory_ingest: bool | None = typer.Option(
2340
+ None,
2341
+ "--spatial-memory-ingest/--no-spatial-memory-ingest",
2342
+ help=(
2343
+ "have the reasoner accumulate a durable "
2344
+ "SpatialMemory from the object-lift producer's "
2345
+ "WorldState.detected_objects so recall_object recalls what the robot "
2346
+ "has seen, and the dashboard shows a scene-objects card + SLAM-map "
2347
+ "markers. **Auto by default**: enabled whenever the object "
2348
+ "detector is."
2349
+ ),
2350
+ ),
2351
+ memory_dir: str | None = typer.Option(
2352
+ None,
2353
+ "--memory-dir",
2354
+ help=(
2355
+ "path to a deploy memory bundle directory. The reasoner "
2356
+ "loads MEMORY.md (semantic memory + memory_write/search tools) from it; "
2357
+ "if the dir also holds scene_graph.json it preloads the 3D world-state "
2358
+ "graph (recall_object), and if it holds map.yaml a nav2 map_server seeds "
2359
+ "the 2D occupancy grid. The dir must exist (the robot writes MEMORY.md "
2360
+ "into it). Overrides the DeployScene's own memory_dir."
2361
+ ),
2362
+ ),
2363
+ dashboard: bool = typer.Option(
2364
+ True,
2365
+ "--dashboard/--no-dashboard",
2366
+ help=(
2367
+ "Auto-spawn the live observability dashboard alongside "
2368
+ "``ros2 launch`` and point OTLP exporters at it. Default: "
2369
+ "on. The dashboard binds to ``--dashboard-port`` (default "
2370
+ "4318) and serves the OpenRAL UI + OTLP/HTTP receiver. "
2371
+ "``--no-dashboard`` is a true headless mode: the dashboard "
2372
+ "child is skipped AND ``OTEL_EXPORTER_OTLP_ENDPOINT`` is "
2373
+ "omitted from every node's env, so the OTel SDK short-"
2374
+ "circuits to no-op (no BatchSpanProcessor → no shutdown "
2375
+ "stall on dead-port retries). Set ``OTEL_EXPORTER_OTLP_"
2376
+ "ENDPOINT`` in the parent shell to forward to an external "
2377
+ "collector instead."
2378
+ ),
2379
+ ),
2380
+ foxglove: bool = typer.Option(
2381
+ False,
2382
+ "--foxglove/--no-foxglove",
2383
+ help=(
2384
+ "spawn the read-only Foxglove WebSocket bridge "
2385
+ "as part of the deploy-sim runtime graph. Default: off. "
2386
+ "When enabled, open Foxglove Studio and connect via "
2387
+ "``ws://127.0.0.1:<foxglove-port>`` to see live cameras, "
2388
+ "joint states, /tf, and the navigation map. **View-only** "
2389
+ "(cannot actuate the robot): ``clientPublish``, "
2390
+ "``services``, and ``parameters`` capabilities are omitted "
2391
+ "from the bridge. Only Bucket-1 topics are exposed "
2392
+ "(safety/e-stop/action topics are never forwarded). "
2393
+ "Requires ``foxglove_bridge`` installed in the workspace "
2394
+ "(ros-${ROS_DISTRO}-foxglove-bridge)."
2395
+ ),
2396
+ ),
2397
+ foxglove_port: int = typer.Option(
2398
+ 8765,
2399
+ "--foxglove-port",
2400
+ help=(
2401
+ "Foxglove WebSocket port (ws://127.0.0.1:<port>). "
2402
+ "Default 8765 (the foxglove_bridge upstream default). "
2403
+ "Ignored unless ``--foxglove`` is set."
2404
+ ),
2405
+ ),
2406
+ initial_task: str | None = typer.Option(
2407
+ None,
2408
+ "--initial-task",
2409
+ help=(
2410
+ "Single natural-language goal the reasoner decomposes into ordered subtasks "
2411
+ "via ``decompose_mission``, e.g. ``--initial-task 'pick the bowl and place "
2412
+ "it on the plate, then push the mug back'``. Passed as "
2413
+ "``initial_task_prompt`` to the launch. When omitted, no startup prompt is "
2414
+ "set and the reasoner idles until a manual ``openral prompt`` or dashboard "
2415
+ "prompt arrives."
2416
+ ),
2417
+ ),
2418
+ dry_run: bool = typer.Option(
2419
+ False,
2420
+ "--dry-run",
2421
+ help=(
2422
+ "Print the resolved ``ros2 launch`` argv + HAL params and exit "
2423
+ "without writing the HAL params temp file or shelling out."
2424
+ ),
2425
+ ),
2426
+ ) -> None:
2427
+ r"""Boot the full ROS graph against a robot's digital-twin HAL.
2428
+
2429
+ Example::
2430
+
2431
+ openral deploy sim --config scenes/deploy/openarm_tabletop.yaml
2432
+ openral deploy sim --config scenes/deploy/openarm_tabletop.yaml \
2433
+ --hal viewer_enabled=false
2434
+ """
2435
+ try:
2436
+ overrides = _parse_hal_overrides(hal)
2437
+ invocation = resolve_launch_invocation(
2438
+ config=config,
2439
+ robot_override=robot,
2440
+ dashboard_port=dashboard_port,
2441
+ reset_to_pose_service=reset_to_pose_service,
2442
+ approach_skill_id=approach_skill_id,
2443
+ dataset_out=dataset_out,
2444
+ dataset_repo_id=dataset_repo_id,
2445
+ dataset_license=dataset_license,
2446
+ hal_param_overrides=overrides,
2447
+ enable_slam=enable_slam,
2448
+ enable_nav2=enable_nav2,
2449
+ enable_octomap=enable_octomap,
2450
+ enable_octomap_kernel_check=enable_octomap_kernel_check,
2451
+ enable_object_detector=enable_object_detector,
2452
+ object_detector_onnx=object_detector_onnx,
2453
+ object_detector_manifest=object_detector_manifest,
2454
+ object_detector_query=object_detector_query,
2455
+ enable_reward_monitor=enable_reward_monitor,
2456
+ reward_monitor_manifest=reward_monitor_manifest,
2457
+ reward_monitor_task=reward_monitor_task,
2458
+ enable_critic=enable_critic,
2459
+ object_detector_locators=object_detector_locator,
2460
+ spatial_memory_ingest=spatial_memory_ingest,
2461
+ memory_dir=memory_dir,
2462
+ enable_dashboard=dashboard,
2463
+ enable_foxglove=foxglove,
2464
+ foxglove_port=foxglove_port,
2465
+ initial_task_prompt=initial_task,
2466
+ )
2467
+ except ROSConfigError as exc:
2468
+ _console.print(f"[red]config error:[/red] {exc}")
2469
+ raise typer.Exit(code=1) from exc
2470
+
2471
+ _console.print(
2472
+ f"[cyan]deploy sim[/cyan] → robot=[bold]{invocation.robot_id}[/bold] "
2473
+ f"(manifest.name=[bold]{invocation.robot_manifest_name}[/bold]) "
2474
+ f"hal_package=[bold]{invocation.hal.package}[/bold] "
2475
+ f"hal_node_name=[bold]{invocation.hal.node_name}[/bold]"
2476
+ )
2477
+ _console.print(f" robot_yaml: {invocation.robot_yaml}")
2478
+ _console.print(f" reset_service: {invocation.reset_to_pose_service}")
2479
+ _console.print(f" approach_skill: {invocation.approach_skill_id or '(snap)'}")
2480
+ _console.print(f" hal_params: {invocation.hal_params}")
2481
+ _console.print(
2482
+ " slam: "
2483
+ + (
2484
+ "[green]enabled[/green] (auto from robot.capabilities.has_lidar — "
2485
+ "Reasoner drives → ACTIVE via LifecycleTransitionTool)"
2486
+ if invocation.enable_slam
2487
+ else "[dim]disabled[/dim] (robot.capabilities.has_lidar=false; "
2488
+ "pass --enable-slam to force on)"
2489
+ )
2490
+ )
2491
+ _console.print(
2492
+ " nav2: "
2493
+ + (
2494
+ "[green]enabled[/green] (Nav2 advertises /navigate_to_pose; "
2495
+ "Reasoner dispatches OpenRAL/rskill-nav2-navigate-to-pose)"
2496
+ if invocation.enable_nav2
2497
+ else "[dim]disabled[/dim] (tracks --enable-slam; pass --enable-nav2 to force on)"
2498
+ )
2499
+ )
2500
+ _console.print(
2501
+ " octomap: "
2502
+ + (
2503
+ "[green]enabled[/green] (octomap_server + bridge → "
2504
+ "/openral/world_voxels; kernel voxel check on when the robot "
2505
+ "has collision capsules)"
2506
+ if invocation.enable_octomap
2507
+ else "[dim]disabled[/dim] (no depth SensorSpec; pass --enable-octomap to force on)"
2508
+ )
2509
+ )
2510
+ _console.print(
2511
+ " detector: "
2512
+ + (
2513
+ f"[green]enabled[/green] (ros_image_detector_node → "
2514
+ f"/openral/perception/objects → object-lift; onnx="
2515
+ f"{invocation.object_detector_onnx})"
2516
+ if invocation.enable_object_detector
2517
+ else "[dim]disabled[/dim] (onnx weights not found at "
2518
+ f"{invocation.object_detector_onnx}; pass --enable-object-detector "
2519
+ "to force on)"
2520
+ )
2521
+ )
2522
+ _console.print(
2523
+ " dashboard: "
2524
+ + (
2525
+ f"[green]auto-spawn[/green] at http://127.0.0.1:{dashboard_port}/"
2526
+ if dashboard
2527
+ else "[dim]disabled[/dim] (pass --dashboard to auto-spawn)"
2528
+ )
2529
+ )
2530
+ _console.print(
2531
+ " foxglove: "
2532
+ + (
2533
+ f"[green]enabled[/green] (view-only, cannot actuate) at ws://127.0.0.1:{foxglove_port}"
2534
+ if foxglove
2535
+ else "[dim]disabled[/dim] (pass --foxglove to enable read-only live scene view)"
2536
+ )
2537
+ )
2538
+ _console.print(" envelope: synthesised at launch time from robot.yaml (no envelope file)")
2539
+ _console.print(
2540
+ " startup_prompt: "
2541
+ + (
2542
+ f"[green]{invocation.initial_task_prompt!r}[/green] "
2543
+ "(from --initial-task; delivered to reasoner at activate)"
2544
+ if invocation.initial_task_prompt
2545
+ else "[dim](none — reasoner idles until openral prompt or dashboard)[/dim]"
2546
+ )
2547
+ )
2548
+
2549
+ if dry_run:
2550
+ printed = [
2551
+ arg.replace("HAL_PARAMS_FILE_PLACEHOLDER", "<hal-params-tmp>")
2552
+ for arg in invocation.argv_template
2553
+ ]
2554
+ _console.print(f" argv: {shlex.join(printed)}")
2555
+ return
2556
+
2557
+ if shutil.which("ros2") is None:
2558
+ _console.print(
2559
+ "[red]ros2 not found on PATH.[/red] Source your ROS 2 install "
2560
+ "(e.g. ``source /opt/ros/jazzy/setup.bash``) and the OpenRAL "
2561
+ "workspace overlay (``source install/setup.bash``) before "
2562
+ "``openral deploy sim``."
2563
+ )
2564
+ raise typer.Exit(code=1)
2565
+
2566
+ # Pre-flight: ``ros2`` is on PATH, but is the OpenRAL workspace
2567
+ # overlay sourced and current? ``ros2 launch`` would otherwise fail
2568
+ # with a terse "Package 'openral_rskill_ros' not found, searching:
2569
+ # ['/opt/ros/jazzy']" — the same wording for missing overlay AND
2570
+ # for a stale build that omits the HAL. Catch both up front.
2571
+ try:
2572
+ assert_ros2_packages_discoverable(_required_ros2_packages(invocation))
2573
+ except ROSConfigError as exc:
2574
+ _console.print(f"[red]config error:[/red] {exc}")
2575
+ raise typer.Exit(code=1) from exc
2576
+
2577
+ _reap_orphans_with_log()
2578
+
2579
+ # Probe the in-tree rSkill registry against this robot's
2580
+ # capabilities. If every capability-matching skill is blocked by a
2581
+ # missing extras group, prompt-and-install on a TTY, or fail with
2582
+ # the install command non-interactively. Without this the reasoner
2583
+ # silently degrades to ``palette_empty`` ticks once everything is
2584
+ # up — the user gets a running but useless deploy.
2585
+ _preflight_palette_deps(
2586
+ repo_root=_repo_root_from(Path(__file__)),
2587
+ robot_yaml=Path(invocation.robot_yaml),
2588
+ )
2589
+
2590
+ # VLA↔reward VRAM pair preflight. A VLA must run with its reward
2591
+ # model resident; verify the pair fits the GPU BEFORE bringing up
2592
+ # ROS. No-op unless the reward monitor is active and the GPU budget is readable;
2593
+ # hard-exits (before launch) when no capability-matched VLA can co-reside with
2594
+ # the reward model.
2595
+ if invocation.enable_reward_monitor and invocation.reward_monitor_manifest:
2596
+ from openral_core import RobotDescription
2597
+
2598
+ _preflight_reward_vram_fit(
2599
+ repo_root=_repo_root_from(Path(__file__)),
2600
+ description=RobotDescription.from_yaml(str(invocation.robot_yaml)),
2601
+ reward_manifest_path=invocation.reward_monitor_manifest,
2602
+ gpu_budget_gb=_detect_gpu_free_vram_gb(),
2603
+ )
2604
+
2605
+ # Write the ephemeral HAL params YAML (lifetime = subprocess) and
2606
+ # substitute its path into argv. ROS 2 parameter YAML uses the
2607
+ # ``/**`` wildcard so the file binds against the HAL's node name
2608
+ # regardless of robot. SIM115's "use a context manager" doesn't
2609
+ # fit — the file must outlive the Python ``with`` block so the
2610
+ # spawned HAL process can open it.
2611
+ hal_params_tmp = tempfile.NamedTemporaryFile( # noqa: SIM115 # reason: HAL reads after this scope
2612
+ mode="w",
2613
+ prefix=f"openral-hal-params-{invocation.robot_id}-",
2614
+ suffix=".yaml",
2615
+ delete=False,
2616
+ encoding="utf-8",
2617
+ )
2618
+ try:
2619
+ yaml.safe_dump(
2620
+ {"/**": {"ros__parameters": invocation.hal_params}},
2621
+ hal_params_tmp,
2622
+ sort_keys=False,
2623
+ )
2624
+ hal_params_tmp.close()
2625
+
2626
+ argv = [
2627
+ arg.replace("HAL_PARAMS_FILE_PLACEHOLDER", hal_params_tmp.name)
2628
+ for arg in invocation.argv_template
2629
+ ]
2630
+ _console.print(f" hal_params_tmp:{hal_params_tmp.name}")
2631
+ _console.print(f" argv: {shlex.join(argv)}")
2632
+
2633
+ # `ros2 launch` runs under the system Python by default; the
2634
+ # launch's deferred imports (openral_core, openral_safety) live
2635
+ # in the workspace venv (the one `uv run` is using right now).
2636
+ # The launch file processes editable-install ``.pth`` files via
2637
+ # ``site.addsitedir`` keyed on ``OPENRAL_VENV_SITE``; export
2638
+ # that env var alongside PYTHONPATH so both the launch parser
2639
+ # and the spawned Python nodes import openral_core correctly.
2640
+ # Also prepend the venv's bin dir to PATH so ``#!/usr/bin/env
2641
+ # python3`` shebangs on spawned node executables resolve to the
2642
+ # venv interpreter — that's the only interpreter that processes
2643
+ # the editable ``.pth`` files via site.py at startup. PYTHONPATH
2644
+ # alone is not enough: .pth files in PYTHONPATH directories are
2645
+ # never processed by Python's site module.
2646
+ venv_env = _prepare_launch_env()
2647
+
2648
+ # The dashboard child is spawned by ``sim_e2e.launch.py`` itself
2649
+ # (gated on ``enable_dashboard:=true`` forwarded from ``dashboard``
2650
+ # above), so this wrapper just runs the launch. The earlier
2651
+ # design wrapped this in ``attached_dashboard(enabled=dashboard,
2652
+ # ...)``, but that would double-spawn the dashboard and trip
2653
+ # ``[Errno 98] address already in use``.
2654
+ #
2655
+ # ``_run_launch`` (not a bare ``subprocess.run``) puts the launch
2656
+ # in its own session and forwards SIGINT/SIGTERM to the group so
2657
+ # every node — and the static_transform_publisher /
2658
+ # robot_state_publisher / HAL it spawns — is reaped on shutdown
2659
+ # instead of orphaning onto ``/tf_static`` + the GPU.
2660
+ returncode = _run_launch(argv, venv_env)
2661
+ raise typer.Exit(code=returncode)
2662
+ finally:
2663
+ with contextlib.suppress(OSError):
2664
+ Path(hal_params_tmp.name).unlink(missing_ok=True)