Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 63 additions & 8 deletions .agents/skills/configure-multi-robot/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: configure-multi-robot
description: Configure, name, and isolate multiple robots in AirStack. Use whenever launching multi-robot, multiple robots, swarm, or fleet scenarios; setting ROBOT_NAME; debugging cross-robot topic collisions; choosing a ROS_DOMAIN_ID; or namespacing topics, TF frames, and DDS bridges across robots.
description: Configure, name, and isolate multiple robots in AirStack — fleet files (config/fleets/, airstack up --fleet) first, legacy NUM_ROBOTS second. Use whenever launching multi-robot, multiple robots, swarm, or fleet scenarios; mixing different stacks/vehicles per robot (heterogeneous fleets, split placement via hosts:); setting ROBOT_NAME; debugging cross-robot topic collisions; choosing a ROS_DOMAIN_ID; or namespacing topics, TF frames, and DDS bridges across robots.
license: Apache-2.0
metadata:
author: AirLab CMU
Expand All @@ -13,7 +13,8 @@ metadata:

Reach for this skill any time you:

- Spawn more than one robot in simulation (`NUM_ROBOTS > 1`)
- Spawn more than one robot in simulation (`--fleet <name>` or legacy `NUM_ROBOTS > 1`)
- Need robots that differ (stack, vehicle, or offboard placement) — a **heterogeneous fleet**
- Deploy multiple physical aircraft (VOXL, Jetson, etc.)
- Debug topic collisions, missing topics on `/robot_2/...`, or "two robots talking on the same topic"
- Write a new launch file or YAML config that hardcodes a topic path
Expand All @@ -29,9 +30,51 @@ If you only ever touch one robot, you can usually skip this skill — but the mo
- Basic understanding of ROS 2 namespaces and TF frame names
- You have already read [`docs/robot/docker/robot_identity.md`](../../../docs/robot/docker/robot_identity.md), or are willing to as you go — that file is the canonical reference for the resolution mechanism

## How ROBOT_NAME Flows Through the Stack
## Fleet-First: Declare the Whole Deployment in One File

`ROBOT_NAME` is **not** a single static value. It is computed per container at shell start by `robot/docker/.bashrc` and propagated into every ROS launch substitution. The full chain:
Since RFC #380 P6, the preferred way to run multiple robots is a **fleet file**
(`config/fleets/*.yaml`): who exists, which vehicle each flies, which stack
each runs, and which ground host runs each split stack's offboard half. Full
guide: [`docs/development/fleets.md`](../../../docs/development/fleets.md).

```bash
airstack fleet list # what exists + shape
airstack up --fleet sim_one_default --sim isaac # 1 robot, today's defaults
airstack up --fleet sim_three_mixed --sim isaac # heterogeneous: 3 robots, 3 stacks + a split
```

What `--fleet <name>` does:

- validates the fleet (named errors), exports `FLEET_CONFIG_FILE` (container
path), and **derives `NUM_ROBOTS`** from the robot count (explicit env
`NUM_ROBOTS` still wins, with a banner)
- on Isaac, switches an untouched-default `ISAAC_SIM_SCRIPT_NAME` to the
generic fleet spawner `fleet_spawn.py` (spawns/scene/sensors from the fleet
+ vehicle files)
- **homogeneous** fleets (same vehicle + stack everywhere) keep
`deploy.replicas`; each replica resolves its own entry via
`tools/fleet/resolve_fleet.py` in `.bashrc` (opt-in: only when
`FLEET_CONFIG_FILE` is set)
- **heterogeneous** fleets get generated per-robot services
(`airstack fleet generate <fleet>` →
`.airstack/generated/docker-compose.fleet.yaml`, auto-included; the `fleet`
compose profile replaces `desktop`)
- a robot with `hosts: {offboard: gcs}` on a split stack gets its `onboard`
entry point, and the named ground host gets a service running the same
stack with `AIRSTACK_STACK_ENTRY=offboard` — the declared successor of the
`desktop_split` / `offboard` profiles

Test harness: `airstack test -m liveliness --fleet sim_three_mixed ...`
passes `FLEET_CONFIG_FILE` + the derived `NUM_ROBOTS`; without `--fleet`,
`--num-robots` behaves exactly as before.

Everything below — the legacy `NUM_ROBOTS` + `robot_name_map` path — remains
the default without a fleet and is still fully supported; the topic/TF
namespacing rules and pitfalls apply identically under both paths.

## How ROBOT_NAME Flows Through the Stack (Legacy Path)

`ROBOT_NAME` is **not** a single static value. It is computed per container at shell start by `robot/docker/.bashrc` and propagated into every ROS launch substitution. When `FLEET_CONFIG_FILE` is set, a fleet branch in `.bashrc` resolves the whole fleet entry first (name, domain, stack placement, vehicle — pre-set env still wins per variable, and failures fall back to the legacy resolver below). The legacy chain:

```
.env (ROBOT_NAME_MAP_CONFIG_FILE, NUM_ROBOTS)
Expand Down Expand Up @@ -137,9 +180,9 @@ does work because `docker exec -e` sets it in the process environment:
docker exec -e ROBOT_NAME=robot_5 -e ROS_DOMAIN_ID=5 -it airstack-robot-desktop-1 bash
```

## Launching Multiple Robots
## Launching Multiple Robots (Legacy `NUM_ROBOTS` Path)

AirStack launches multiple robots as **replicas of the same container**, not as multiple namespaces inside one container. Look at [`robot/docker/docker-compose.yaml`](../../../robot/docker/docker-compose.yaml):
Prefer `airstack up --fleet <name>` (above). Without a fleet, AirStack launches multiple robots as **replicas of the same container**, not as multiple namespaces inside one container — which is also why replicas can only ever be *identical* robots (heterogeneous fleets need the generated per-robot services). Look at [`robot/docker/docker-compose.yaml`](../../../robot/docker/docker-compose.yaml):

```yaml
robot-desktop:
Expand All @@ -160,7 +203,9 @@ docker ps --format '{{.Names}}' | grep robot-desktop

The simulator side has to spawn matching vehicles — see [Sim-Side Robot Spawning](#sim-side-robot-spawning).

### `onboard_all` vs `onboard_local_offboard_global`
### `onboard_all` vs `onboard_local_offboard_global` (legacy roles)

The stack-shaped successors: `--stack full_default` / `--stack lite_default` replace the role values, `--stack lite_offload_global:onboard|:offboard` replaces the split pair, and a fleet entry's `hosts: {offboard: <ground>}` replaces choosing the split *placement* by hand (see Fleet-First above). The role dispatch below still works without a stack/fleet.

[`autonomy_bringup`](../../../robot/ros_ws/src/autonomy_bringup/) ships two layouts, selected by the `role` arg / `AUTONOMY_ROLE` env var:

Expand Down Expand Up @@ -246,6 +291,8 @@ The `ms-airsim` container's `entrypoint.sh` (in `simulation/ms-airsim/docker/`)

### Isaac Sim (Pegasus)

With a fleet, [`fleet_spawn.py`](../../../simulation/isaac-sim/launch_scripts/fleet_spawn.py) is selected automatically: spawn positions come from each robot's `spawn:`, the scene from `sim.scene`, and sensor toggles from the vehicle manifests (any `lidar*` sensor enables the RTX lidar subgraph — the per-vehicle `ENABLE_LIDAR` equivalent). The legacy path:

[`simulation/isaac-sim/launch_scripts/example_multi_px4_pegasus_launch_script.py`](../../../simulation/isaac-sim/launch_scripts/example_multi_px4_pegasus_launch_script.py) reads `NUM_ROBOTS` and calls `spawn_drone(i)` in a loop. Each drone is created with `robot_name=f"robot_{index}"`, `vehicle_id=index`, `domain_id=index`, and an X offset for spacing:

```python
Expand Down Expand Up @@ -280,8 +327,13 @@ CLI passthrough:

```bash
airstack test -m takeoff_hover_land --sim msairsim --num-robots 1,3 -v
airstack test -m liveliness --sim isaacsim --fleet sim_three_mixed -v # fleet-first
```

With `--fleet`, the fixture sets `FLEET_CONFIG_FILE`, derives `NUM_ROBOTS`
from the fleet, and pins `ISAAC_SIM_SCRIPT_NAME=fleet_spawn.py` on Isaac;
`env["fleet"]` carries the fleet name for tests that need it.

## Common Pitfalls

### 1. Hardcoding the robot name in topics
Expand Down Expand Up @@ -427,7 +479,10 @@ docker exec -e ROS_DOMAIN_ID=1 airstack-robot-desktop-1 bash -c \

## References

- [`docs/robot/docker/robot_identity.md`](../../../docs/robot/docker/robot_identity.md) — canonical reference for the resolution mechanism
- [`docs/development/fleets.md`](../../../docs/development/fleets.md) — fleets: hierarchy, file tour, split placement, migration table (fleet-first path)
- [`config/fleets/`](../../../config/fleets/) — `sim_one_default.yaml` (parity with legacy), `sim_three_mixed.yaml` (heterogeneous + split)
- [`tools/fleet/resolve_fleet.py`](../../../tools/fleet/resolve_fleet.py) — fleet-entry resolver (`--table` to inspect, `--validate` to check)
- [`docs/robot/docker/robot_identity.md`](../../../docs/robot/docker/robot_identity.md) — canonical reference for the legacy resolution mechanism
- [`docs/robot/autonomy_modes.md`](../../../docs/robot/autonomy_modes.md) — profile matrix (`desktop`, `desktop_split`, `voxl`, `l4t`, `offboard`)
- [`robot/docker/robot_name_map/`](../../../robot/docker/robot_name_map/) — mapping YAMLs and `resolve_robot_name.py`
- [`robot/ros_ws/src/autonomy_bringup/launch/robot.launch.xml`](../../../robot/ros_ws/src/autonomy_bringup/launch/robot.launch.xml) — top-level `push_ros_namespace`
Expand Down
160 changes: 160 additions & 0 deletions .airstack/modules/fleet.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
#!/usr/bin/env bash

# fleet.sh — `airstack fleet` command group (RFC #380 §2, Phase P6).
#
# Manages fleet files (config/fleets/*.yaml): who exists, which body (vehicle),
# which brain (stack), and which hosts run each split's offboard half.
#
# Subcommands: list | generate | help
# Resolution logic lives in tools/fleet/resolve_fleet.py; compose generation in
# tools/fleet/generate_fleet_compose.py. Guide: docs/development/fleets.md

FLEETS_DIR="${PROJECT_ROOT}/config/fleets"
FLEET_RESOLVER="${PROJECT_ROOT}/tools/fleet/resolve_fleet.py"
FLEET_COMPOSE_GENERATOR="${PROJECT_ROOT}/tools/fleet/generate_fleet_compose.py"
FLEET_GENERATED_COMPOSE="${PROJECT_ROOT}/.airstack/generated/docker-compose.fleet.yaml"

function _fleet_check_python {
if ! command -v python3 >/dev/null 2>&1; then
log_error "python3 is required for 'airstack fleet' commands."
return 1
fi
if ! python3 -c 'import yaml' 2>/dev/null; then
log_error "PyYAML is required (pip3 install --user pyyaml)."
return 1
fi
}

# Resolve a fleet argument (name or path) to a host-side file path.
function _fleet_file_of {
local ref="$1"
if [[ "$ref" == *.yaml || "$ref" == */* ]]; then
[[ "$ref" != /* ]] && ref="$PROJECT_ROOT/$ref"
echo "$ref"
else
echo "$FLEETS_DIR/$ref.yaml"
fi
}

# ── subcommands ──────────────────────────────────────────────────────────────

function cmd_fleet_list {
_fleet_check_python || return 1
if [ ! -d "$FLEETS_DIR" ]; then
log_error "No fleets directory at ${FLEETS_DIR}."
return 1
fi
FLEETS_DIR="$FLEETS_DIR" PROJECT_ROOT_ENV="$PROJECT_ROOT" \
FLEET_RESOLVER="$FLEET_RESOLVER" python3 - <<'PY'
import importlib.util, os, sys

spec = importlib.util.spec_from_file_location("airstack_resolve_fleet", os.environ["FLEET_RESOLVER"])
rf = importlib.util.module_from_spec(spec)
spec.loader.exec_module(rf)

fleets_dir = os.environ["FLEETS_DIR"]
root = os.environ["PROJECT_ROOT_ENV"]

rows = []
for fname in sorted(os.listdir(fleets_dir)):
if not fname.endswith(".yaml"):
continue
name = fname[: -len(".yaml")]
path = os.path.join(fleets_dir, fname)
try:
fleet = rf.load_fleet(path)
errors = rf.validate_fleet(fleet, root)
if errors:
rows.append((name, "?", "?", "?", f"INVALID: {errors[0][:60]}"))
continue
resolved = rf.resolve_fleet(fleet, root)
robots = resolved["robots"]
stacks = sorted({r["stack_ref"] for r in robots})
vehicles = sorted({r["vehicle"] for r in robots})
split = any(r["hosts"] for r in robots)
homogeneous = rf.fleet_is_homogeneous(fleet, root)
shape = "homogeneous" if homogeneous else "heterogeneous"
if split:
shape += "+split"
rows.append((name, str(len(robots)), ",".join(vehicles), ",".join(stacks), shape))
except rf.FleetError as exc:
rows.append((name, "?", "?", "?", f"INVALID: {str(exc)[:60]}"))

if not rows:
print("No fleet files under config/fleets/. See docs/development/fleets.md.")
raise SystemExit(0)

headers = ("FLEET", "ROBOTS", "VEHICLES", "STACKS", "SHAPE")
widths = [max(len(str(r[i])) for r in rows + [headers]) for i in range(5)]
fmt = " ".join("{:<%d}" % w for w in widths)
print(fmt.format(*headers))
for row in rows:
print(fmt.format(*row))
PY
}

function cmd_fleet_generate {
_fleet_check_python || return 1
local ref="${1:-}"
if [ -z "$ref" ] || [ $# -gt 1 ]; then
log_error "Usage: airstack fleet generate <fleet>"
log_error " e.g. airstack fleet generate sim_three_mixed"
return 1
fi
local fleet_file
fleet_file="$(_fleet_file_of "$ref")"
if [ ! -f "$fleet_file" ]; then
log_error "Fleet not found: ${fleet_file}"
cmd_fleet_list
return 1
fi
python3 "$FLEET_COMPOSE_GENERATOR" "$fleet_file" --project-root "$PROJECT_ROOT" || return 1
_fleet_generate_bridge_routers "$fleet_file"
}

# Split stacks referenced by the fleet need their bridge-derived DDS-router
# configs materialized before launch (the onboard entry loads
# .airstack/generated/dds_router.<stack>.yaml and fails fast without it).
function _fleet_generate_bridge_routers {
local fleet_file="$1" bridge stack_dir stack_name
while IFS= read -r stack_dir; do
bridge="$PROJECT_ROOT/$stack_dir/bridge.yaml"
[ -f "$bridge" ] || continue
stack_name="$(basename "$stack_dir")"
log_info "Generating DDS-router config for split stack '${stack_name}' (bridge.yaml)..."
python3 "$PROJECT_ROOT/tools/gen_dds_router.py" "$bridge" || return 1
done < <(FLEET_FILE="$fleet_file" python3 - <<'PY'
import os, yaml
with open(os.environ["FLEET_FILE"], encoding="utf-8") as f:
doc = yaml.safe_load(f) or {}
stacks = set()
default = ((doc.get("defaults") or {}).get("stack"))
for robot in (doc.get("robots") or {}).values():
stacks.add((robot or {}).get("stack") or default)
for s in sorted(s for s in stacks if s):
print(s)
PY
)
}

# Dispatcher for the `fleet` command group.
function cmd_fleet_dispatch {
local sub="${1:-help}"
if [ $# -gt 0 ]; then shift; fi
case "$sub" in
list) cmd_fleet_list "$@" ;;
generate) cmd_fleet_generate "$@" ;;
help|-h|--help) print_command_help fleet ;;
*)
log_error "Unknown fleet subcommand: '$sub'"
print_command_help fleet
return 1
;;
esac
}

# Register commands from this module.
function register_fleet_commands {
COMMANDS["fleet"]="cmd_fleet_dispatch"
COMMAND_HELP["fleet"]="Manage fleet files: list|generate (RFC #380 §2; see 'airstack help fleet')"
}
7 changes: 5 additions & 2 deletions .airstack/modules/ready.sh
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,12 @@ function _ready_ros2_exec {
timeout $timeout_s $cmd" 2>/dev/null
}

# List running robot containers (compose replicas), one per line.
# List running robot containers, one per line: compose replicas
# (airstack-robot-desktop-N) AND fleet-generated per-robot services
# (airstack-robot_N-1). Ground hosts (gcs-robot_N tenants) are NOT robots —
# they never run MAVROS/PX4 — so exclude them.
function _ready_robot_containers {
docker ps --format '{{.Names}}' | grep -E -- '-robot-' | sort
docker ps --format '{{.Names}}' | grep -E -- '-robot[-_]' | grep -v 'gcs-' | sort
}

# domain for robot container (via the same .bashrc resolution airstack status uses)
Expand Down
Loading
Loading