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:
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:
NavigateGoalstruct with the goal fieldsNavigateFeedbackstruct with the feedback fieldsNavigateResultstruct with the result fieldsNavigatemarker type implementing theActiontrait
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
| Policy | Behavior |
|---|---|
PreemptOld | New goals cancel the active goal (default) |
RejectNew | Reject new goals while one is active |
Priority | Higher-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
GoalStatus
| Status | Description |
|---|---|
Pending | Received but not yet executing |
Active | Currently executing |
Succeeded | Completed successfully |
Aborted | Failed during execution |
Canceled | Canceled by client request |
Preempted | Canceled by higher-priority goal |
Rejected | Rejected 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 */ }
}
| Error | Cause |
|---|---|
GoalRejected(reason) | on_goal returned Reject |
GoalCanceled | Goal was canceled |
GoalPreempted | Goal was preempted by higher priority |
GoalTimeout | Execution exceeded timeout |
ServerUnavailable | No 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:
Navigate
// 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
| Gain | Cost |
|---|---|
| Progress visibility — feedback at configurable rate | More complex than fire-and-forget topics |
| Cancellation and preemption — interrupt tasks safely | Server must check is_cancel_requested() in its execute loop |
| Goal validation — reject bad goals before execution | Extra on_goal handler to implement |
| Priority-based preemption — high-priority goals auto-cancel lower ones | Must choose preemption policy (default: PreemptOld) |
| Built on topics — inherits shared-memory speed and cross-process transparency | Three internal topics per action (goal, feedback, result) |
See Also
- Actions API — Full Rust API reference
- Services — Synchronous request/response (for quick operations)
- Communication Overview — When to use topics vs services vs actions
- Scheduler — Full Reference — Running action nodes in the scheduler