Actions

Beta: The Actions API is functional in Rust but still maturing. Python bindings are not yet available. The API may change in future releases.

Your robot needs to navigate across a warehouse, pick up a package, and bring it back. That takes 30 seconds. How does the operator know it's working? How do they cancel if something goes wrong? How does a new high-priority goal interrupt the current one?

Topics can't do this — they're fire-and-forget. Services can't either — they block until done, with no progress updates. Actions solve this:

Action pattern: goal → progress feedback → cancel/complete

Use actions when:

  • The task takes more than one tick (navigation, arm motion, calibration)
  • You need progress updates (distance remaining, percent complete)
  • You need to cancel or preempt in-flight tasks
  • You need to know if the task succeeded or failed

Defining an Action

Use the action! macro to define Goal, Feedback, and Result types:

// simplified
use horus::prelude::*;

action! {
    /// Navigate to a target position
    Navigate {
        goal {
            target_x: f64,
            target_y: f64,
            max_speed: f64 = 1.0,  // Default value
        }
        feedback {
            distance_remaining: f64,
            percent_complete: f32,
        }
        result {
            success: bool,
            final_x: f64,
            final_y: f64,
        }
    }
}

This generates:

  • NavigateGoal struct with the goal fields
  • NavigateFeedback struct with the feedback fields
  • NavigateResult struct with the result fields
  • Navigate marker type implementing the Action trait

Standard Action Templates

For common robotics patterns, use the standard_action! shortcut:

// simplified
standard_action!(navigate MyNavAction);    // Goal: target pose, Feedback: distance, Result: final pose
standard_action!(manipulate MyPickPlace);  // Goal: object + target, Feedback: phase, Result: success
standard_action!(wait MyWaitAction);       // Goal: duration, Feedback: elapsed, Result: completed
standard_action!(dock MyDockAction);       // Goal: dock ID, Feedback: alignment, Result: docked

For a simple action with single fields per section, you can still use the action! macro:

// simplified
action! {
    Spin {
        goal { angular_velocity: f64 }
        feedback { current_angle: f64 }
        result { total_rotations: u32 }
    }
}

Action Server

The action server receives goals, executes them, and sends back feedback and results.

Building a Server

// simplified
let server = ActionServerNode::<Navigate>::builder()
    // Validate incoming goals
    .on_goal(|goal| {
        if goal.max_speed <= 0.0 {
            GoalResponse::Reject("Speed must be positive".into())
        } else {
            GoalResponse::Accept
        }
    })
    // Handle cancellation requests
    .on_cancel(|goal_id| {
        hlog!(info, "Cancel requested for {:?}", goal_id);
        CancelResponse::Accept
    })
    // Execute the action
    .on_execute(|handle| {
        let goal = handle.goal();
        let mut distance = ((goal.target_x).powi(2) + (goal.target_y).powi(2)).sqrt();
        let total = distance;

        while distance > 0.1 {
            // Check for cancellation
            if handle.is_cancel_requested() {
                return handle.canceled(NavigateResult {
                    success: false,
                    final_x: goal.target_x - distance,
                    final_y: goal.target_y - distance,
                });
            }

            // Simulate movement
            distance -= goal.max_speed * 0.1;

            // Publish feedback
            handle.publish_feedback(NavigateFeedback {
                distance_remaining: distance.max(0.0),
                percent_complete: ((total - distance) / total * 100.0) as f32,
            });

            std::thread::sleep(std::time::Duration::from_millis(100));
        }

        handle.succeed(NavigateResult {
            success: true,
            final_x: goal.target_x,
            final_y: goal.target_y,
        })
    })
    .build();

Server Configuration

// simplified
let server = ActionServerNode::<Navigate>::builder()
    .on_goal(|_| GoalResponse::Accept)
    .on_execute(|handle| { /* ... */ handle.succeed(result) })
    .max_concurrent_goals(Some(1))          // Only one goal at a time
    .feedback_rate(20.0)                     // 20 Hz feedback rate
    .goal_timeout(Duration::from_secs(30))  // Timeout after 30s
    .preemption_policy(PreemptionPolicy::PreemptOld)  // New goals preempt active
    .build();

Preemption Policies

PolicyBehavior
PreemptOldNew goals cancel the active goal (default)
RejectNewReject new goals while one is active
PriorityHigher-priority goals preempt lower-priority ones
Queue { max_size }Queue goals in FIFO order

ServerGoalHandle

The handle passed to on_execute provides:

// simplified
handle.goal_id()              // Unique goal identifier
handle.goal()                 // The goal request (&A::Goal)
handle.priority()             // Goal priority level
handle.status()               // Current GoalStatus
handle.elapsed()              // Time since goal started
handle.is_cancel_requested()  // Client requested cancellation?
handle.is_preempt_requested() // Higher-priority goal arrived?
handle.should_abort()         // Timeout or other abort condition?
handle.publish_feedback(fb)   // Send feedback to client

// Terminal methods (consume the handle):
handle.succeed(result)        // -> GoalOutcome::Succeeded
handle.abort(result)          // -> GoalOutcome::Aborted
handle.canceled(result)       // -> GoalOutcome::Canceled
handle.preempted(result)      // -> GoalOutcome::Preempted

Server Metrics

// simplified
let metrics = server.metrics();
println!("Goals received: {}", metrics.goals_received);
println!("Active: {}, Queued: {}", metrics.active_goals, metrics.queued_goals);
println!("Succeeded: {}, Aborted: {}", metrics.goals_succeeded, metrics.goals_aborted);

Action Client

Async Client (Node-Based)

Use ActionClientNode when running inside a scheduler:

// simplified
let client = ActionClientNode::<Navigate>::builder()
    .on_feedback(|goal_id, feedback| {
        println!("Progress: {:.0}%", feedback.percent_complete);
    })
    .on_result(|goal_id, status, result| {
        println!("Goal {:?} finished: {:?}", goal_id, status);
    })
    .build();

// Send a goal
let handle = client.send_goal(NavigateGoal {
    target_x: 5.0,
    target_y: 3.0,
    max_speed: 1.0,
})?;

// Or with priority
let handle = client.send_goal_with_priority(goal, GoalPriority::HIGH)?;

ClientGoalHandle

// simplified
handle.goal_id()          // Unique goal ID
handle.status()           // Current GoalStatus
handle.is_active()        // Pending or Active?
handle.is_done()          // In terminal state?
handle.is_success()       // Succeeded?
handle.elapsed()          // Time since sent
handle.last_feedback()    // Most recent feedback (Option)
handle.result()           // Final result if done (Option)
handle.cancel()           // Request cancellation

// Blocking wait
let result = handle.await_result(Duration::from_secs(10));

// Wait with feedback callback
let result = handle.await_result_with_feedback(
    Duration::from_secs(10),
    |feedback| println!("Distance: {:.1}m", feedback.distance_remaining),
)?;

Sync Client (Standalone)

Use SyncActionClient for simple scripts without a scheduler:

// simplified
let client = SyncActionClient::<Navigate>::new()?;

// Blocking call
let result = client.send_goal_and_wait(
    NavigateGoal { target_x: 5.0, target_y: 3.0, max_speed: 1.0 },
    Duration::from_secs(30),
)?;

// With feedback
let result = client.send_goal_and_wait_with_feedback(
    goal,
    Duration::from_secs(30),
    |feedback| println!("{:.0}% complete", feedback.percent_complete),
)?;

Goal Lifecycle

Action protocol: GoalRequest → StatusUpdate → Feedback... → Result

GoalStatus

StatusDescription
PendingReceived but not yet executing
ActiveCurrently executing
SucceededCompleted successfully
AbortedFailed during execution
CanceledCanceled by client request
PreemptedCanceled by higher-priority goal
RejectedRejected by on_goal validation

GoalPriority

// simplified
GoalPriority::HIGHEST  // 0 - Critical tasks
GoalPriority::HIGH     // 64
GoalPriority::NORMAL   // 128 (default)
GoalPriority::LOW      // 192
GoalPriority::LOWEST   // 255 - Background tasks

Running Actions in a Scheduler

// simplified
fn main() -> Result<()> {
    let mut scheduler = Scheduler::new();

    // Action server
    scheduler.add(
        ActionServerNode::<Navigate>::builder()
            .on_goal(|_| GoalResponse::Accept)
            .on_execute(|handle| {
                // ... navigation logic ...
                handle.succeed(result)
            })
            .build()
    ).order(0).build()?;

    // Action client
    scheduler.add(
        ActionClientNode::<Navigate>::builder()
            .on_result(|_, status, result| {
                println!("Navigation {:?}: arrived={}", status, result.success);
            })
            .build()
    ).order(1).build()?;

    scheduler.run()
}

Error Handling

// simplified
match client.send_goal(goal) {
    Ok(handle) => { /* goal accepted */ }
    Err(ActionError::GoalRejected(reason)) => { /* validation failed */ }
    Err(ActionError::ServerUnavailable) => { /* no server running */ }
    Err(ActionError::GoalTimeout) => { /* server didn't respond */ }
    Err(e) => { /* other error */ }
}
ErrorCause
GoalRejected(reason)on_goal returned Reject
GoalCanceledGoal was canceled
GoalPreemptedGoal was preempted by higher priority
GoalTimeoutExecution exceeded timeout
ServerUnavailableNo action server found
CommunicationError(msg)IPC failure
ExecutionError(msg)Error during execution
InvalidGoal(msg)Malformed goal data
GoalNotFound(id)Unknown goal ID

CLI Commands

# List active actions
horus action list

# Get action details
horus action info navigate

Prebuilt Action Patterns

The standard_action! macro provides ready-to-use action definitions for common robotics tasks:

// simplified
use horus::prelude::*;

standard_action!(navigate);

// Creates: NavigateGoal, NavigateFeedback, NavigateResult, Navigate
let goal = NavigateGoal {
    target_x: 5.0,
    target_y: 3.0,
    target_theta: Some(1.57),
    max_speed: 1.0,
};

Goal: target_x, target_y, target_theta (optional), max_speed (default 1.0) Feedback: distance_remaining, current_x, current_y, progress_percent Result: success, final_x, final_y, final_theta, distance_traveled, time_elapsed

Manipulate

// simplified
standard_action!(manipulate);
// Creates: ManipulateGoal, ManipulateFeedback, ManipulateResult, Manipulate

Goal: object_id, target_x/y/z, force_limit (default 10.0) Feedback: phase, grip_force, ee_x/y/z, progress_percent Result: success, error_message, object_x/y/z

Wait

// simplified
standard_action!(wait);
// Creates: WaitGoal, WaitFeedback, WaitResult, Wait

Goal: duration_secs Feedback: time_remaining, progress_percent Result: completed, actual_duration

Dock

// simplified
standard_action!(dock);
// Creates: DockGoal, DockFeedback, DockResult, Dock

Goal: dock_id, approach_speed (default 0.1) Feedback: phase, distance_to_dock, alignment_error, dock_detected Result: success, docked_id, contact_force

Design Decisions

Why Goal / Feedback / Result instead of just request/response? A navigation task takes 30 seconds. With request/response (services), the caller blocks for 30 seconds with no visibility into progress — it doesn't know if the robot is moving, stuck, or crashed. The Goal/Feedback/Result pattern separates the initial request (goal), continuous progress updates (feedback), and final outcome (result), giving the caller real-time visibility and the ability to cancel at any point.

Why built on top of topics? Actions use three internal topics ({name}.goal, {name}.feedback, {name}.result) for communication. This means actions inherit all of Topic's properties: shared-memory speed, automatic backend selection, cross-process transparency. It also means actions work anywhere topics work — no separate transport layer.

Why preemption policies instead of manual cancellation only? In production robotics, new high-priority goals must be able to interrupt lower-priority ones automatically. A warehouse robot navigating to shelf B3 must immediately redirect if a higher-priority order arrives. Preemption policies (PreemptOld, Priority, Queue, RejectNew) encode this logic in the action server rather than requiring every client to manage cancellation manually.

Why separate sync and async clients? Inside a scheduler tick, blocking for 30 seconds is unacceptable — you need ActionClientNode with non-blocking goal submission and feedback callbacks. In a standalone script or test, blocking is fine and simpler — you want SyncActionClient. Both use the same underlying topics.

Trade-offs

GainCost
Progress visibility — feedback at configurable rateMore complex than fire-and-forget topics
Cancellation and preemption — interrupt tasks safelyServer must check is_cancel_requested() in its execute loop
Goal validation — reject bad goals before executionExtra on_goal handler to implement
Priority-based preemption — high-priority goals auto-cancel lower onesMust choose preemption policy (default: PreemptOld)
Built on topics — inherits shared-memory speed and cross-process transparencyThree internal topics per action (goal, feedback, result)

See Also