Actions

Beta: The Actions API is functional but still maturing. This page shows the Rust API; for C++ see Tutorial 6: Services and Actions (C++). Python bindings are not yet available. The API may change in future releases.

Actions handle long-running tasks that need progress feedback and cancellation support. Unlike topics (fire-and-forget) or services (request/response), actions provide a full lifecycle: send a goal, receive periodic feedback, and get a final result.

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:

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:

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: time remaining, Result: completed
standard_action!(dock MyDockAction);       // Goal: dock ID, Feedback: alignment, Result: docked

If none of the templates fit, fall back to the general action! form — each section takes a single field just as happily as several:

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

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| {
        // Copy the goal fields out: the terminal methods below consume `handle`,
        // so no borrow of it may still be live when they are called.
        let (target_x, target_y, max_speed) = {
            let g = handle.goal();
            (g.target_x, g.target_y, g.max_speed)
        };
        let mut distance = (target_x.powi(2) + 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: target_x - distance,
                    final_y: target_y - distance,
                });
            }

            // Simulate movement
            distance -= 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: target_x,
            final_y: target_y,
        })
    })
    .build();

Server Configuration

let server = ActionServerNode::<Navigate>::builder()
    .on_goal(|_| GoalResponse::Accept)
    .on_execute(|handle| { /* ... */ handle.succeed(result) })
    .max_concurrent_goals(Some(1))          // one goal admitted at a time
                                            // (a preempted goal can briefly overlap the new one)
    .feedback_rate(20.0)                     // 20 Hz feedback rate
    .goal_timeout(Duration::from_secs(30))  // at 30s the server *requests* cancellation —
                                            // your on_execute must poll should_abort();
                                            // at 60s it reports Aborted to the client
    .preemption_policy(PreemptionPolicy::PreemptOld)  // New goals preempt active
    .build();

Preemption Policies

PolicyBehavior
PreemptOldA new goal asks the active goal to stop and starts immediately on its own thread (default). The old goal runs until it notices the cancel, so the two can briefly overlap
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:

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

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:

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

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 ActionClient (an alias for SyncActionClient) for simple scripts without a scheduler:

let client = ActionClient::<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

Client                          Server
  |                                |
  |--- GoalRequest ------------->  |
  |                                | on_goal() → Accept/Reject
  |<----------- StatusUpdate ---   | (Pending → Active)
  |                                |
  |<----------- Feedback -------   | on_execute() running
  |<----------- Feedback -------   |   publish_feedback()
  |<----------- Feedback -------   |
  |                                |
  |--- CancelRequest ---------->   | (optional)
  |                                | on_cancel() → Accept/Reject
  |                                |
  |<----------- Result ---------   | succeed() / abort() / canceled()
  |                                |

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

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

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).done();

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

    scheduler.run()
}

Error Handling

ActionClientNode::send_goal is non-blocking — it only ever fails with ServerUnavailable. Rejection and every other outcome arrive later, on the handle's status() or in the on_result callback. The blocking client reports them all at the call site:

match client.send_goal_and_wait(goal, Duration::from_secs(30)) {
    Ok(result) => { /* goal succeeded */ }
    Err(ActionError::GoalRejected(reason)) => { /* validation failed */ }
    Err(ActionError::ServerUnavailable) => { /* this client node was never initialized */ }
    Err(ActionError::GoalTimeout) => { /* did not finish in time */ }
    Err(e) => { /* other error */ }
}
ErrorCause
GoalRejected(reason)on_goal returned Reject. The reason string you pass is logged by the server only — the client always receives the generic "Goal rejected", so send anything the caller needs in the result instead
GoalCanceledGoal was canceled
GoalPreemptedGoal was preempted by higher priority
GoalTimeoutExecution exceeded timeout
ServerUnavailableThe client node has not been initialized, so its topics do not exist yet. It says nothing about whether a server is running — HORUS does not detect a missing action server, and a goal sent to one is simply never answered
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

Next Steps