James Harton James Harton

Beyond Behaviour Trees: Task Orchestration the Elixir Way

Why we chose Reactor sagas over behaviour trees for robot task orchestration, and what that means for resilience and safety.

Robot arms don't just move to positions. They pick things up, put them down, respond to sensor feedback, and recover when something goes wrong. Orchestrating these multi-step sequences is one of the harder problems in robotics software.

The standard answer in ROS land is behaviour trees. We went a different direction.

What's a Behaviour Tree?

If you're already familiar with behaviour trees, skip to the next section.

A behaviour tree is a way to structure complex robot behaviours as a tree of nodes. Each node is either a leaf (an action like "move arm" or a condition check like "is object detected?") or a composite that controls execution flow.

The key composite types are:

  • Sequence - runs children left-to-right, stops on first failure
  • Selector (or Fallback) - runs children left-to-right, stops on first success
  • Parallel - runs all children simultaneously, with configurable success/failure policies

A pick-and-place behaviour tree might look like this:

Sequence
├── Selector
│   ├── Condition: "Object in gripper?"
│   └── Sequence
│       ├── Action: "Move to pick position"
│       ├── Action: "Close gripper"
│       └── Condition: "Grip force detected?"
├── Action: "Move to place position"
└── Action: "Open gripper"

The tree gets ticked - a clock pulse that propagates through the tree from root to leaves. Each node returns one of three states: Success, Failure, or Running. The composites use these return values to decide what to do next.

This pattern emerged from game AI, where characters need to evaluate their situation continuously and switch behaviours fluidly. It works well for reactive systems that need to constantly reassess their environment.

The Tick Problem

Behaviour trees are designed for continuous polling.

Every tick, the tree re-evaluates from the root. Is the condition still true? Is the action still running? Should we abort and try a different branch? This polling model makes sense when you're a game character checking if an enemy is nearby sixty times per second.

For robot arms? Less so.

When I tell my WidowX-200 to move to a position, I don't need to poll it every 16 milliseconds to check if it's still moving. The servo controller knows when motion completes. It can tell me. This is what message-passing systems are built for.

Tick-based execution also means your tree traversal logic runs constantly, even when nothing is happening. That's fine on a desktop PC. On a Raspberry Pi running Nerves with other things to do? Every cycle counts.

For Elixir developers: Imagine implementing GenServer where instead of receiving messages, you had to poll a check_state/0 function in a loop. That's essentially what tick-based behaviour trees ask you to do.

For robotics developers: Imagine if your motor controllers just sent you a message when motion completed instead of you having to poll them. That's what Elixir's message passing gives you for free.

Enter Reactor

I wrote Reactor as part of my work on the Ash Framework core team. It's a dynamic, concurrent, dependency-resolving saga orchestrator - a mouthful, but the core idea is simple: you define a series of steps with explicit dependencies, and Reactor figures out how to run them. Concurrently where possible, sequentially where necessary.

The "saga" part is crucial. In distributed systems, a saga is a sequence of operations where each step can define recovery actions:

  • compensate - what to do if this step fails
  • undo - what to do if a downstream step fails (roll back successful work)

This gives you transaction-like semantics across operations that can't actually be wrapped in a database transaction. Like, say, robot movements.

Reactor already handles all the hard parts: dependency graph resolution, concurrent execution, error propagation, rollback coordination. It's been battle-tested in production Ash applications. Building robotics-specific steps on top of it was straightforward.

BB.Reactor

The bb_reactor package extends Reactor with robotics-specific steps. Here's the demo sequence from our WidowX-200 example:

defmodule BB.Example.WX200.Reactor.DemoSequence do
  use Reactor, extensions: [BB.Reactor]

  wait_for_state :ready do
    states [:idle]
    timeout 5000
  end

  command :go_home do
    command :home
    wait_for :ready
  end

  command :trace_circle do
    command :demo_circle
    wait_for :go_home
  end

  command :return_home do
    command :home
    wait_for :trace_circle
  end

  return :return_home
end

The demo sequence running in simulation

No tick loop. No polling. The reactor waits for the robot to reach idle state, then executes commands in sequence, each waiting for the previous to complete via message passing.

The DSL provides three step types:

command - executes a BB command (like moving or gripper control). Can specify a compensate handler for failure recovery.

wait_for_event - subscribes to the BB PubSub and waits for a matching message. Useful for sensor feedback like "wait until force sensor detects grip".

wait_for_state - waits for the robot state machine to reach a target state.

Why This Matters

Safe Failure Recovery

command :move_to_place do
  command :move_to_pose
  argument :target, input(:place_pose)
  wait_for :grip_confirmed
  compensate :return_home
end

If this movement fails, the reactor runs the :return_home command to recover. The arm doesn't get left in an unknown state.

Behaviour trees have no built-in concept of compensation. You can implement it, but you're on your own.

Dependency-Driven Concurrency

Reactor analyses the dependency graph and runs independent steps in parallel automatically. If two steps don't depend on each other, they execute concurrently.

command :prepare_gripper do
  command :open_gripper
  wait_for :ready
end

command :move_to_approach do
  command :move_to_pose
  argument :target, input(:approach_pose)
  wait_for :ready
end

# Both run in parallel - they only depend on :ready
command :grip do
  command :close_gripper
  wait_for [:prepare_gripper, :move_to_approach]  # waits for both
end

With behaviour trees, you'd need explicit Parallel nodes and careful tree structure to achieve the same thing.

Event-Driven, Not Poll-Driven

Steps block on actual events, not tick cycles:

wait_for_event :grip_confirmed do
  path [:sensor, :force_torque]
  message_types [ForceTorque]
  filter &(&1.payload.force > 5.0)
  timeout 2000
end

This subscribes to the force/torque sensor topic and waits for a message where force exceeds 5N. No polling. The step completes the moment the message arrives.

Safety Integration

BB.Reactor monitors for safety state changes. If the robot disarms mid-sequence (emergency stop, hardware fault, whatever), the reactor halts immediately with {:halt, :safety_disarmed}. No more steps execute.

The command step watches the command process and detects disarm events automatically. You don't have to sprinkle safety checks throughout your tree.

The Trade-Off

Behaviour trees excel at reactive, continuous behaviours - things that need to constantly reassess the environment and switch strategies. A robot navigating a dynamic environment, dodging obstacles in real-time, might genuinely benefit from tick-based evaluation.

Reactor sagas excel at structured task sequences - things with clear steps, dependencies, and rollback requirements. Assembly operations, pick-and-place, inspection sequences.

Behaviour trees aren't wrong. They're just not the only tool, and for the kind of deterministic task orchestration that robot arms typically need, sagas are a better fit.

Plus, they're already built on the patterns Elixir gives you for free.

Behaviour Tree Implementations (for comparison)

  • BehaviorTree.CPP - C++ library used with ROS2
  • py_trees - Python library, also used with ROS
  • SMACH - ROS state machine library (predecessor pattern)