Skip to content
Merged
60 changes: 28 additions & 32 deletions .github/workflows/build_and_push_docker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,49 +7,45 @@ on:
- development
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
build-and-push:
runs-on: self-hosted
runs-on: [self-hosted, cpu, heavy, gpu]
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
lfs: true

- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push all images
env:
AMM_IMG_SUFFIX: ${{ env.SUFFIX }}
run: |
docker compose -f docker/compose.yaml build
docker compose -f docker/compose.yaml push

- name: Build and push ROS 2 image
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile.ros2
push: true
tags: |
robotecai/mobile-manipulator-demo-ros2${{ github.ref_name == 'development' && '-development' || '' }}:latest

- name: Build and push Agents image
uses: docker/build-push-action@v6
smoke-test:
needs: build-and-push
runs-on: [self-hosted, cpu, heavy, gpu]
steps:
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
context: .
file: docker/Dockerfile.agents
push: true
build-args: |
ROS2_IMAGE=robotecai/mobile-manipulator-demo-ros2${{ github.ref_name == 'development' && '-development' || '' }}:latest
tags: |
robotecai/mobile-manipulator-demo-agents${{ github.ref_name == 'development' && '-development' || '' }}:latest
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Build and push O3DE image
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile.o3de
push: true
build-args: |
ROS2_IMAGE=robotecai/mobile-manipulator-demo-ros2${{ github.ref_name == 'development' && '-development' || '' }}:latest
tags: |
robotecai/mobile-manipulator-demo-o3de${{ github.ref_name == 'development' && '-development' || '' }}:latest
- name: Headless sim smoke test (/clock)
run: |
IMAGE="robotecai/agentic-mobile-manipulator-o3de:latest"
docker pull "$IMAGE"
docker run --rm --entrypoint pixi "$IMAGE" run sim-smoke

14 changes: 4 additions & 10 deletions docker/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# Build everything: docker compose -f docker/compose.yaml build
# Run the demo: xhost +local:root && docker compose -f docker/compose.yaml up demo
# Attach to a session: docker exec -it $(docker ps -qf ancestor=robotecai/mobile-manipulator-demo:latest) tmux attach -t agentic-mobile-manipulator-sim
# Attach to a session: docker exec -it $(docker ps -qf ancestor=robotecai/agentic-mobile-manipulator:latest) tmux attach -t agentic-mobile-manipulator-sim
# (sessions: -sim -stack -llm-servers -agents -hmi)
#
# Target is single-pc-gpu-and-npu (the default). For a GPU-only image — llama.cpp
Expand All @@ -28,14 +28,14 @@
services:
# ── Base: RoboStack ROS 2 + built workspace ────────────────────────────────
ros2:
image: robotecai/mobile-manipulator-demo-ros2:latest
image: robotecai/agentic-mobile-manipulator-ros2:latest
build:
context: ..
dockerfile: docker/Dockerfile.ros2

# ── Base: + O3DE SDK and built simulation ──────────────────────────────────
o3de:
image: robotecai/mobile-manipulator-demo-o3de:latest
image: robotecai/agentic-mobile-manipulator-o3de:latest
build:
context: ..
dockerfile: docker/Dockerfile.o3de
Expand All @@ -44,7 +44,7 @@ services:

# ── The demo: + agents, local inference engines; runs the whole stack ───────
demo:
image: robotecai/mobile-manipulator-demo:latest
image: robotecai/agentic-mobile-manipulator:latest
build:
context: ..
dockerfile: docker/Dockerfile.demo
Expand All @@ -68,7 +68,6 @@ services:
- /dev/kfd:/dev/kfd # AMD GPU compute (ROCm)
- /dev/dri:/dev/dri # AMD GPU render (Vulkan)
- /dev/accel/accel0:/dev/accel/accel0 # Ryzen AI NPU (amdxdna)
# The NPU pins memory; without unlimited memlock FastFlowLM fails to allocate.
ulimits:
memlock:
soft: -1
Expand All @@ -77,10 +76,5 @@ services:
- /tmp/.X11-unix:/tmp/.X11-unix
- ${XAUTHORITY:-$HOME/.Xauthority}:/root/.Xauthority
- ../config.toml:/root/MobileManipulatorDemo/config.toml
# All weights live under the repo's models/: llama ggufs in models/, NPU
# models in models/flm (FLM_MODEL_PATH below). `pixi run download-models`
# fetches both. No host-home (~/.config/flm) coupling.
- ../models:/root/MobileManipulatorDemo/models
# Single host: ROS 2 DDS + every inference endpoint talk over localhost, and
# host networking lets RViz/ros2 on the host introspect the running demo.
network_mode: host
19 changes: 16 additions & 3 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,24 @@ git clone https://github.com/RobotecAI/agentic-mobile-manipulator.git
cd agentic-mobile-manipulator
```

## 2. Build the image
## 2. Get the image: build _or_ pull

**Build locally:**

```bash
docker compose -f docker/compose.yaml build
```

This builds three cacheable layers — `…-ros2` (RoboStack ROS 2 + workspace) → `…-o3de` (O3DE SDK + simulation) → `…demo` (Python agents + llama.cpp + FastFlowLM). The O3DE layer is large, so the first build takes a while; later rebuilds reuse the cached bases.

**Or pull the prebuilt images:**

```bash
docker compose -f docker/compose.yaml pull
```

If you pull, remember to pass `--no-build` when starting the demo (see step 4).

## 3. Download the model weights

Weights are **not** baked into the image. They are downloaded into the repo's `models/` directory, bind-mounted at run time. `config.toml` is the single source of truth for which models are fetched. Pull them once into `models/` by running:
Expand All @@ -44,9 +54,12 @@ Allow local connections to your X server, then start the `demo` service:

```bash
xhost +local:root
docker compose -f docker/compose.yaml up demo
docker compose -f docker/compose.yaml up demo # if you built (step 2)
docker compose -f docker/compose.yaml up --no-build demo # if you pulled (step 2)
```

> [!IMPORTANT] > `docker compose up` rebuilds any service that has a `build:` section — even when the image is already present locally. So after `pull`, a bare `up demo` ignores the pulled image and rebuilds `ros2 → o3de → demo`. Pass `--no-build` to run the pulled image as-is.

## What to expect

![Demo Windows](demo_windows.jpg)
Expand All @@ -60,7 +73,7 @@ docker compose -f docker/compose.yaml up demo
Each component runs in its own tmux session inside the container. Attach to one (e.g. the simulation) from the host:

```bash
docker exec -it $(docker ps -qf ancestor=robotecai/mobile-manipulator-demo:latest) \
docker exec -it $(docker ps -qf ancestor=robotecai/agentic-mobile-manipulator:latest) \
tmux attach -t agentic-mobile-manipulator-sim
```

Expand Down
4 changes: 4 additions & 0 deletions pixi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@ description = "Launch O3DE simulation in its own tmux session"
cmd = "$DEMO_ROOT/sim/build/linux/bin/profile/MobileManipulatorDemo.GameLauncher -bg_ConnectToAssetProcessor=0"
description = "Launch O3DE simulation in the foreground (no tmux)"

[feature.sim.tasks.sim-smoke]
cmd = "bash scripts/sim_smoke.sh"
description = "Headless sim smoke test: boot with --rhi=null, pass if /clock ticks (no GPU/display)"

[feature.sim.tasks.editor]
cmd = "$O3DE_ENGINE_PATH/bin/Linux/profile/Default/Editor --project-path $DEMO_ROOT/sim"
description = "Launch O3DE Editor from SDK (project must be built first)"
Expand Down
61 changes: 61 additions & 0 deletions scripts/sim_smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Headless smoke test for the O3DE sim.
#
# Boots the GameLauncher with the null renderer (--rhi=null) — no GPU, no X
# display needed — and passes if the simulation is actually *ticking*, proven by
# catching a message on /clock within a timeout. That single check exercises the
# whole cold path: binary runs, assets + level load, PhysX ticks, and the ROS 2
# bridge publishes.
#
# NOT checked: camera / rendered sensors. Those need the Atom (Vulkan) renderer,
# which headless needs a DRI3-capable display for — Xvfb doesn't provide DRI3, so
# the GPU+Vulkan path wedges on swapchain creation. rhi=null has no renderer at
# all, so camera topics are advertised but never publish. Verifying rendered
# output headless is a separate, unsolved problem; keep it out of the fast gate.
#
# Run: pixi run sim-smoke
# In Docker: docker run --rm --entrypoint pixi <o3de-image> run sim-smoke
# Tune: SIM_SMOKE_TIMEOUT=120 pixi run sim-smoke
# SIM_SMOKE_TOPIC=/joint_states SIM_SMOKE_TYPE=sensor_msgs/msg/JointState pixi run sim-smoke
set -uo pipefail

# Hermetic: only ever see our own sim on loopback, so a neighbouring sim on a
# shared CI runner can't cause a false pass.
export ROS_LOCALHOST_ONLY=1

LAUNCHER="${DEMO_ROOT:-$PWD}/sim/build/linux/bin/profile/MobileManipulatorDemo.GameLauncher"
TIMEOUT="${SIM_SMOKE_TIMEOUT:-90}" # boot + first message; rhi=null is faster than Vulkan
TOPIC="${SIM_SMOKE_TOPIC:-/clock}"
# Type must be explicit: with no publisher up yet, `ros2 topic echo` can't infer
# the type from the graph and bails instantly instead of waiting.
TYPE="${SIM_SMOKE_TYPE:-rosgraph_msgs/msg/Clock}"
LOG="$(mktemp)"

[ -x "$LAUNCHER" ] || { echo "[FAIL] launcher not built: $LAUNCHER"; exit 1; }

# GameLauncher ignores SIGINT (won't stop on Ctrl+C), so hard-kill on exit.
# Kill the captured PID, plus a pkill by full command line as a fallback (the
# comm name isn't reliably set during early boot). pkill -f is safe: this shell's
# own argv is just "bash scripts/sim_smoke.sh", so it can't match itself.
LAUNCHER_PID=""
cleanup() {
[ -n "$LAUNCHER_PID" ] && kill -9 "$LAUNCHER_PID" 2>/dev/null
pkill -9 -f 'MobileManipulatorDemo.GameLauncher' 2>/dev/null
}
trap cleanup EXIT

echo "[smoke] launching sim headless (--rhi=null)"
env -u DISPLAY "$LAUNCHER" -bg_ConnectToAssetProcessor=0 --rhi=null >"$LOG" 2>&1 &
LAUNCHER_PID=$!

# /clock is published BEST_EFFORT; a default (reliable) subscriber gets nothing.
echo "[smoke] waiting up to ${TIMEOUT}s for a message on ${TOPIC} (${TYPE})"
if timeout "$TIMEOUT" ros2 topic echo --once --qos-reliability best_effort "$TOPIC" "$TYPE" >/dev/null 2>&1; then
echo "[PASS] ${TOPIC} is publishing — sim ticks headless"
exit 0
fi

echo "[FAIL] no message on ${TOPIC} within ${TIMEOUT}s"
echo "----- last 30 log lines -----"
tail -30 "$LOG"
exit 1
Loading