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
394 changes: 104 additions & 290 deletions .agents/skills/write-launch-file/SKILL.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ PROJECT_NAME="airstack"
# If you've run ./airstack.sh setup, then this will auto-generate from the git commit hash every time a change is made
# to a Dockerfile or docker-compose.yaml file. Otherwise this can also be set explicitly to make a release version.
# auto-generated from git commit hash
VERSION="0.20.0-alpha.2"
VERSION="0.20.0-alpha.3"
# Choose "dev" or "prebuilt". "dev" is for mounted code that must be built live. "prebuilt" is for built ros_ws baked into the image
DOCKER_IMAGE_BUILD_MODE="dev"
# Where to push and pull images from. Can replace with your docker hub username if using docker hub.
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/module-system-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,16 @@ jobs:
echo "All required images present after pull/retag — skipping build."
fi

# Build the module's Docker layer chain (RFC #379 §6) — must run AFTER
# the trunk base images are present (chain builds FROM them). The
# zero-module identity rule makes this a fast no-op for modules with no
# docker-relevant declarations; for tier-2/3 modules (Dockerfile.module /
# overlay_image) it builds the composed image and points the generated
# compose override at it, so the test bring-up actually exercises the
# module's dependency layers.
- name: Build module Docker layers
run: ./airstack.sh module lock --build

- name: Run tests
env:
AIRSTACK_ROOT: ${{ github.workspace }}
Expand Down
61 changes: 60 additions & 1 deletion airstack.sh
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ function print_command_help {
echo "Options:"
echo " --build Build images before starting containers"
echo " --recreate Recreate containers even if their configuration and image haven't changed"
echo " --stack NAME Launch a stack folder (stacks/NAME/launch/stack.launch.xml) instead of"
echo " the legacy AUTONOMY_ROLE dispatch. NAME:ENTRY selects an alternate entry"
echo " file (launch/ENTRY.launch.xml). See docs/development/stacks.md."
;;
images)
echo "Usage: airstack images"
Expand Down Expand Up @@ -959,6 +962,7 @@ function parse_launch_intent {
AIRSTACK_INTENT_HEADLESS=""
AIRSTACK_INTENT_PLAY=""
AIRSTACK_INTENT_AUTOLAUNCH=""
AIRSTACK_INTENT_STACK=""
AIRSTACK_DRY_RUN=""
AIRSTACK_UP_WAIT=""

Expand All @@ -974,6 +978,8 @@ function parse_launch_intent {
--play) AIRSTACK_INTENT_PLAY="true";;
--no-play) AIRSTACK_INTENT_PLAY="false";;
--no-autolaunch) AIRSTACK_INTENT_AUTOLAUNCH="false";;
--stack) i=$((i+1)); AIRSTACK_INTENT_STACK="${args[$i]:-}";;
--stack=*) AIRSTACK_INTENT_STACK="${a#--stack=}";;
--wait) AIRSTACK_UP_WAIT="1";;
# NOTE: shadows compose's own `up --dry-run`; ours validates the
# derived launch config and exits without starting services.
Expand All @@ -994,9 +1000,44 @@ function parse_launch_intent {
return 0
}

# Validate a --stack selection against the host stacks/ tree and export the
# CONTAINER paths the launch dispatch reads (stacks/ is bind-mounted at
# /root/AirStack/stacks by robot-base-docker-compose.yaml). Accepts the
# split-entry form `<name>:<entry>` (entry names launch/<entry>.launch.xml;
# reserved for split stacks — RFC #380 §2). Default entry: stack.
function apply_stack_intent {
local stack_name="$AIRSTACK_INTENT_STACK" stack_entry="stack"
if [[ "$stack_name" == *:* ]]; then
stack_entry="${stack_name#*:}"
stack_name="${stack_name%%:*}"
fi
if [[ -z "$stack_name" || -z "$stack_entry" ]]; then
log_error "--stack requires a stack name (got '$AIRSTACK_INTENT_STACK'; expected <name> or <name>:<entry>)"
return 1
fi
local stack_host_dir="$PROJECT_ROOT/stacks/$stack_name"
if [[ ! -d "$stack_host_dir" ]]; then
local available
available=$(ls -1 "$PROJECT_ROOT/stacks" 2>/dev/null | grep -v '^\.' | tr '\n' ' ')
log_error "Unknown stack '$stack_name' — $stack_host_dir does not exist. Available stacks: ${available:-<none>}"
return 1
fi
if [[ ! -f "$stack_host_dir/launch/$stack_entry.launch.xml" ]]; then
log_error "Stack '$stack_name' has no entry point launch/$stack_entry.launch.xml (expected $stack_host_dir/launch/$stack_entry.launch.xml)"
return 1
fi
export AIRSTACK_STACK_DIR="/root/AirStack/stacks/$stack_name"
export AIRSTACK_STACK_ENTRY="$stack_entry"
return 0
}

# Derive + export env vars from the parsed intent. Args: remaining CLI args
# (scanned for --env-file when resolving current values).
function apply_launch_intent {
if [[ -n "$AIRSTACK_INTENT_STACK" ]]; then
apply_stack_intent || return 1
fi

if [[ -n "$AIRSTACK_INTENT_SIM" ]]; then
local sim_profile urdf
case "$AIRSTACK_INTENT_SIM" in
Expand Down Expand Up @@ -1063,7 +1104,8 @@ function apply_launch_intent {
function print_launch_config {
local keys=(COMPOSE_PROFILES NUM_ROBOTS URDF_FILE AUTOLAUNCH PLAY_SIM_ON_START
ISAAC_SIM_SCRIPT_NAME ISAAC_SIM_USE_STANDALONE ISAAC_SIM_HEADLESS
MS_AIRSIM_HEADLESS VERSION DOCKER_IMAGE_BUILD_MODE)
MS_AIRSIM_HEADLESS AIRSTACK_STACK_DIR AIRSTACK_STACK_ENTRY
VERSION DOCKER_IMAGE_BUILD_MODE)
local k v lines=()
for k in "${keys[@]}"; do
v=$(resolve_launch_var "$k" "$@")
Expand All @@ -1077,6 +1119,11 @@ function print_launch_config {
log_info " isaac: script=$(resolve_launch_var ISAAC_SIM_SCRIPT_NAME "$@") headless=$(resolve_launch_var ISAAC_SIM_HEADLESS "$@")"
fi
log_info " urdf=$(resolve_launch_var URDF_FILE "$@")"
local _stack_dir
_stack_dir=$(resolve_launch_var AIRSTACK_STACK_DIR "$@")
if [[ -n "$_stack_dir" ]]; then
log_info " stack: dir=$_stack_dir entry=$(resolve_launch_var AIRSTACK_STACK_ENTRY "$@")"
fi

echo "--- effective launch config ---"
printf '%s\n' "${lines[@]}"
Expand Down Expand Up @@ -1175,6 +1222,18 @@ function preflight_up {
log_warn "Docker $docker_major < 29: container-name DNS resolution fails, robots will resolve as 'unknown_robot' on domain 0 (MAVROS will not connect). Upgrade Docker or set ROBOT_NAME_SOURCE=hostname."
fi

# 7. Stack vs legacy AUTONOMY_ROLE dispatch. AUTONOMY_ROLE counts as
# "explicitly set" only via env / --env-file / .env — the compose files'
# own `${AUTONOMY_ROLE:-full}` default is invisible here, by design.
local _pf_stack_dir _pf_role
_pf_stack_dir=$(resolve_launch_var AIRSTACK_STACK_DIR "${_pf_global[@]}")
_pf_role=$(resolve_launch_var AUTONOMY_ROLE "${_pf_global[@]}")
if [[ -n "$_pf_role" && -z "$_pf_stack_dir" ]]; then
log_warn "AUTONOMY_ROLE is the legacy dispatch; stacks replace it in 0.21 — try: airstack up --stack full_default"
elif [[ -n "$_pf_role" && -n "$_pf_stack_dir" ]]; then
log_warn "Both a stack ($_pf_stack_dir) and AUTONOMY_ROLE=$_pf_role are set — the stack wins: robot.launch.xml ignores the role when a stack dir is set."
fi

unset -f _pf_error
return $errors
}
Expand Down
134 changes: 134 additions & 0 deletions docs/development/stacks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# AirStack Stacks

A **stack** is a self-contained folder that defines a complete robot topology:
which modules run and how they are wired together ([RFC #379 §3](https://github.com/castacks/AirStack/discussions/379)).
Trunk ships a small set of **reference stacks** under `stacks/`; custom stacks
live with their owners (a lab keeps a private stack repo wiring together
public modules — no fork of AirStack needed).

## Stack folder anatomy

Plain files, no schema beyond a required layout (from [RFC #385 §1](https://github.com/castacks/AirStack/discussions/385)):

| File | Purpose |
|------|---------|
| `modules.repos` | vcstool format, **pinned** to tags/commits — never branches. A stack with a pinned `.repos` *is* a localized release set. Carries a top-level `airstack_compat:` key (sibling of `repositories:`; vcstool ignores it) declaring the trunk semver range the stack was tested against. |
| `launch/stack.launch.xml` | **THE wiring document**: a flat list of module `<include>`s. All cross-module remaps and topic-arg overrides live here — nowhere else (the single-locus rule). Unsplit stacks have exactly this one entry point; split stacks (RFC #380 §2) carry one entry file per host role plus `bridge.yaml`. |
| `docker-compose.yaml` | Composes this stack's images from module layers (RFC #379 §6). A documented stub until the stack pins modules. |
| `wiring.md` | **Generated** from the *running* graph by the wiring-snapshot test — never hand-edited. Drift-checked in CI. |
| `README.md` | What this stack is for, how to run it, its known limits. |

Anatomy is enforced by a unit test: `tests/meta/test_stack_layout_contract.py`
(`wiring.md` is optional until the first snapshot run commits it).

## Reference stacks in trunk

| Stack | Topology |
|-------|----------|
| [`full_default`](https://github.com/castacks/AirStack/tree/develop/stacks/full_default) | The current full-autonomy topology (GPU `droan_gl` planner) — baseline, graph-identical to legacy `AUTONOMY_ROLE=full`. |
| [`full_droan_cpu`](https://github.com/castacks/AirStack/tree/develop/stacks/full_droan_cpu) | CPU DROAN planner + live `disparity_expansion` — absorbs `local_droan_cpu.launch.xml`. |
| [`full_macvo`](https://github.com/castacks/AirStack/tree/develop/stacks/full_macvo) | MAC-VO as the planner's disparity source — supersedes (and fixes) the broken `local_macvo_obstacle_avoidance.launch.xml` variant. |

## Wrap vs. flatten — current status

Stack adoption is a two-step migration:

- **Wrap form (now):** each reference stack's `stack.launch.xml` *includes*
the existing layer bringup files (`interface_bringup`, `local_bringup`, …),
capturing today's topology without moving any wiring. The remaps still live
inside those layer bringups.
- **Flatten (next phases):** the layer bringups' nodes and remaps move into
the stack entry files, the legacy files shrink, and their lines disappear
from the lint allowlist (below). `autonomy_bringup` thins until the
AUTONOMY_ROLE dispatch is gone.

In both forms, the stack's `wiring.md` — snapshotted from the running system —
is the observed truth of the graph.

## Running a stack

```bash
airstack up --stack full_default --sim isaac --robots 1
airstack ready
```

Mechanics: `--stack <name>` validates `stacks/<name>/launch/stack.launch.xml`
exists, then exports `AIRSTACK_STACK_DIR=/root/AirStack/stacks/<name>` (the
*container* path — `stacks/` is bind-mounted into every robot container) and
`AIRSTACK_STACK_ENTRY=stack`. Inside the container,
`autonomy_bringup/launch/robot.launch.xml` still runs the shared preamble
(ROBOT_NAME namespace, `use_sim_time`, `robot_state_publisher`, world→map TF),
then includes the stack entry file *instead of* the legacy role groups.

`--stack <name>:<entry>` selects an alternate entry file
(`launch/<entry>.launch.xml`) — reserved for split stacks (RFC #380 §2).

Stack launch files need no `colcon build` — they are read from the bind mount;
edit and re-launch.

## wiring.md: generation and drift-checking

The wiring-snapshot system test brings the stack up in sim, waits for the node
graph to settle, captures it (`ros2 node list` + `ros2 topic info --verbose`),
and renders a mermaid dataflow document:

```bash
airstack test -m wiring --stack full_default --sim isaacsim --num-robots 1
```

- **No `stacks/<name>/wiring.md` committed yet (bootstrap):** the test PASSES
and logs an INSTRUCTION pointing at the observed snapshot
(`tests/results/<run>/wiring/observed_<name>.md`). Validate it, copy it to
`stacks/<name>/wiring.md`, commit.
- **Committed:** the test fails on any drift between the committed diagram and
the observed graph — a PR that changes wiring must regenerate `wiring.md`,
so the review diff shows the topology change.

Legacy runs without `--stack` keep using the golden at
`tests/goldens/wiring/full_default.<sim>.<N>robot.md`.

## The single-locus rule (and its lint)

All cross-module remaps and topic-arg overrides live in the stack's entry
launch file(s). Module launch files declare topic args (canonical defaults per
[the integration checklist](../robot/autonomy/integration_checklist.md), a
`description=` on every arg) but **never** `<remap>` and never hardcode
cross-module topics. The stack file *is* the wiring diagram; `grep -r
global_plan stacks/my_stack/` answers "who touches this".

Enforced by `tests/meta/test_launch_single_locus.py` (`unit` mark, runs in CI):

1. No `<remap>`/`remappings=` outside `stacks/*/launch/` — except files frozen
in `tests/meta/launch_lint_allowlist.txt` (the wrap-form legacy set).
2. The allowlist only shrinks: an entry whose file no longer carries a remap
fails the lint until its line is deleted.
3. Stack launch files must describe every `<arg>` they declare.

See the [write-launch-file skill](https://github.com/castacks/AirStack/blob/develop/.agents/skills/write-launch-file/SKILL.md)
for the authoring workflow.

## Making a new stack today

1. Copy a reference stack: `cp -r stacks/full_default stacks/my_stack`
(`airstack stack new` arrives in a later phase).
2. Edit `launch/stack.launch.xml` — swap/add/remove module `<include>`s and
their topic args. Update `README.md`.
3. Run it: `airstack up --stack my_stack --sim isaac`.
4. Snapshot the wiring: `airstack test -m wiring --stack my_stack`, validate
the observed file, commit it as `stacks/my_stack/wiring.md`.
5. `airstack test -m unit` — the layout contract and the launch lint must pass.

## AUTONOMY_ROLE deprecation

`AUTONOMY_ROLE` remains fully functional while stacks land, but it is the
legacy dispatch — stacks replace it in 0.21. `airstack up` warns when it sees
an explicitly set `AUTONOMY_ROLE`:

| You have | What happens | Migration |
|----------|--------------|-----------|
| No stack, no explicit `AUTONOMY_ROLE` | Legacy dispatch, compose default role (`full`) — unchanged, no warning | `airstack up --stack full_default` when ready |
| Explicit `AUTONOMY_ROLE=full` (env / `.env` / `--env-file`) | Legacy dispatch + deprecation warning | `--stack full_default` |
| `AUTONOMY_ROLE=full` + `local_launch_file:=local_droan_cpu.launch.xml` | Legacy dispatch + warning | `--stack full_droan_cpu` |
| `local_macvo_obstacle_avoidance.launch.xml` | Broken (wrong arg names, stale topic) | `--stack full_macvo` (fixed) |
| `AUTONOMY_ROLE=onboard` / `offboard` (split) | Legacy dispatch + warning | split stacks arrive with RFC #380 §2 (`lite_offload_global`) |
| `--stack X` **and** explicit `AUTONOMY_ROLE` | Stack wins — the role is ignored by the launch dispatch; warning says so | Drop `AUTONOMY_ROLE` |
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ nav:
- Advanced Tutorials:
- AI Agent Guide: docs/development/advanced/ai_agent_guide.md
- AirStack Modules: docs/development/modules.md
- AirStack Stacks: docs/development/stacks.md
- AirStack CLI Tool:
- Extending: docs/development/advanced/airstack-cli/extending.md
- Architecture: docs/development/advanced/airstack-cli/architecture.md
Expand Down
7 changes: 7 additions & 0 deletions robot/docker/robot-base-docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ services:
- DEBUG_RVIZ=${DEBUG_RVIZ:-false}
# OptiTrack / NatNet
- NATNET_SERVER_IP=${NATNET_SERVER_IP:-172.31.0.200}
# Stack dispatch (RFC #379): set by `airstack up --stack <name>` to the
# CONTAINER path of the stack folder (/root/AirStack/stacks/<name>).
# Empty = legacy AUTONOMY_ROLE dispatch in robot.launch.xml.
- AIRSTACK_STACK_DIR=${AIRSTACK_STACK_DIR:-}
- AIRSTACK_STACK_ENTRY=${AIRSTACK_STACK_ENTRY:-stack}
volumes:
# display stuff
- $HOME/.Xauthority:/.Xauthority
Expand All @@ -40,6 +45,8 @@ services:
- ../../common/ros_packages:/root/AirStack/robot/ros_ws/src/common:rw
- ../../common/fastdds.xml:/root/AirStack/robot/ros_ws/src/fastdds.xml
- ../ros_ws:/root/AirStack/robot/ros_ws:rw
# reference stack folders (launch entry points read via AIRSTACK_STACK_DIR)
- ../../stacks:/root/AirStack/stacks:rw
# bags
- ../bags:/bags:rw

25 changes: 23 additions & 2 deletions robot/ros_ws/src/autonomy_bringup/launch/robot.launch.xml
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
<!-- Single entry point for all robot deployments.
role choices:
role choices (legacy AUTONOMY_ROLE dispatch):
full - all autonomy modules run on this machine (default: sim/dev desktop, autonomous Jetson)
onboard - lite modules only: interface, sensors, perception, local planning, behavior (VOXL, Jetson
lite, desktop_split sim)
offboard - global planning only: runs on GCS paired with onboard robots (robot-offboard service)
stack dispatch (RFC #379): when AIRSTACK_STACK_DIR is set (airstack up
with the stack flag), the stack folder's entry launch file runs instead
of the role groups; role is ignored. The shared preamble below runs in
both modes.
-->
<launch>
<arg name="sim" default="true" />
Expand All @@ -13,6 +17,15 @@ lite, desktop_split sim)
<arg name="gossip_domain" default="99" />
<arg name="gossip_publish_rate" default="1.0" />

<!-- Stack dispatch (RFC #379 S3): when stack_dir is non-empty, the stack
folder's launch/<stack_entry>.launch.xml replaces the AUTONOMY_ROLE
role groups below. Defaults come from env vars so both launch wrappers
(desktop_bringup, autonomy_bringup) work unchanged; `airstack up` with
the stack flag exports them (container paths, e.g.
/root/AirStack/stacks/full_default). Empty = legacy role dispatch. -->
<arg name="stack_dir" default="$(env AIRSTACK_STACK_DIR '')" />
<arg name="stack_entry" default="$(env AIRSTACK_STACK_ENTRY stack)" />

<push_ros_namespace namespace="$(env ROBOT_NAME)" />

<set_parameter name="use_sim_time" value="true" if="$(var sim)" />
Expand All @@ -30,7 +43,13 @@ lite, desktop_split sim)
name="world_to_map_broadcaster"
args="0 0 0 0 0 0 world map" />

<!-- ── Role dispatch ───────────────────────────────────────────────── -->
<!-- ── Stack dispatch (takes precedence over role dispatch) ─────────── -->
<group if="$(eval '&quot;$(var stack_dir)&quot; != &quot;&quot;')">
<include file="$(var stack_dir)/launch/$(var stack_entry).launch.xml" />
</group>

<!-- ── Role dispatch (legacy; skipped entirely when a stack is selected) -->
<group unless="$(eval '&quot;$(var stack_dir)&quot; != &quot;&quot;')">

<!-- full: every autonomy module runs here -->
<group if="$(eval '&quot;$(var role)&quot; == &quot;full&quot;')">
Expand Down Expand Up @@ -77,4 +96,6 @@ lite, desktop_split sim)
file="$(find-pkg-share autonomy_bringup)/onboard_local_offboard_global/launch/offboard_autonomy_global.launch.xml" />
</group>

</group>

</launch>
Loading
Loading