Skip to main content

mecha10_behavior_runtime/actions/
capability_task.rs

1//! Generic action node that hands control to a capability node (e.g. `rl`, `instance-track`) and
2//! waits for it to report task completion.
3//!
4//! Replaces the old bespoke `RlTaskNode`/`InstanceTrackTaskNode` pair. Which of the two old
5//! behaviors this node exhibits is selected purely by whether `target_request_topic` is
6//! configured:
7//!
8//! - Not configured: mirrors `RlTaskNode` exactly - target presence is never tracked, this node
9//!   stays `Running` until any message arrives on `task_complete_topic`, then resolves to
10//!   `Success` forever after.
11//! - Configured: mirrors `InstanceTrackTaskNode` exactly - this node also subscribes to
12//!   `target_request_topic`, tracks whether a target is currently live from the latest
13//!   `{"bbox": <value-or-null>}`-shaped message seen there, and resolves to `Running` while a
14//!   target is live or `Failure` otherwise. Any `task_complete_topic` message clears the target
15//!   immediately, same as the old `InstanceTrackTaskNode`.
16
17use crate::{BehaviorNode, Context, NodeStatus, TreeStatusSnapshot};
18use async_trait::async_trait;
19use mecha10_core::Receiver;
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22
23/// A target request message, parsed generically - only whether `bbox` is present and non-null
24/// matters, so the value itself is never inspected beyond that.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26struct TargetRequest {
27    #[serde(default)]
28    bbox: Option<Value>,
29}
30
31#[derive(Debug, Serialize, Deserialize)]
32struct CapabilityTaskConfig {
33    capability: String,
34    task_complete_topic: String,
35    #[serde(default)]
36    target_request_topic: Option<String>,
37}
38
39pub struct CapabilityTaskNode {
40    capability: String,
41    task_complete_topic: String,
42    target_request_topic: Option<String>,
43    complete_receiver: Option<Receiver<Value>>,
44    target_receiver: Option<Receiver<Value>>,
45    complete: bool,
46    has_target: bool,
47    last_status: Option<NodeStatus>,
48}
49
50impl std::fmt::Debug for CapabilityTaskNode {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        f.debug_struct("CapabilityTaskNode")
53            .field("capability", &self.capability)
54            .field("task_complete_topic", &self.task_complete_topic)
55            .field("target_request_topic", &self.target_request_topic)
56            .field("complete_subscribed", &self.complete_receiver.is_some())
57            .field("target_subscribed", &self.target_receiver.is_some())
58            .field("complete", &self.complete)
59            .field("has_target", &self.has_target)
60            .finish()
61    }
62}
63
64impl CapabilityTaskNode {
65    /// Create a new node from JSON configuration. `capability` and `task_complete_topic` are
66    /// required; `target_request_topic` is optional and absent by default, which selects the
67    /// old `RlTaskNode` behavior (see module doc).
68    pub fn from_json(config: Value) -> anyhow::Result<Self> {
69        let cfg: CapabilityTaskConfig = serde_json::from_value(config)?;
70        Ok(Self {
71            capability: cfg.capability,
72            task_complete_topic: cfg.task_complete_topic,
73            target_request_topic: cfg.target_request_topic,
74            complete_receiver: None,
75            target_receiver: None,
76            complete: false,
77            has_target: false,
78            last_status: None,
79        })
80    }
81
82    /// Construct a node with already-connected receivers, bypassing live subscription.
83    ///
84    /// Used by unit tests to exercise convergence without a real `Context`/Redis.
85    #[cfg(test)]
86    fn with_receivers(
87        capability: &str,
88        task_complete_topic: &str,
89        target_request_topic: Option<&str>,
90        complete_receiver: Receiver<Value>,
91        target_receiver: Option<Receiver<Value>>,
92    ) -> Self {
93        Self {
94            capability: capability.to_string(),
95            task_complete_topic: task_complete_topic.to_string(),
96            target_request_topic: target_request_topic.map(|s| s.to_string()),
97            complete_receiver: Some(complete_receiver),
98            target_receiver,
99            complete: false,
100            has_target: false,
101            last_status: None,
102        }
103    }
104
105    /// Drains every pending target request, updating `has_target` from the last one seen (if
106    /// any) - mirrors `SetCapabilityTargetNode`'s "only the most recent request matters"
107    /// semantics.
108    fn poll_target(&mut self) {
109        let Some(receiver) = self.target_receiver.as_mut() else {
110            return;
111        };
112
113        let mut latest = None;
114        while let Ok(value) = receiver.try_recv() {
115            match serde_json::from_value::<TargetRequest>(value) {
116                Ok(request) => latest = Some(request.bbox.is_some()),
117                Err(e) => tracing::warn!("CapabilityTaskNode: dropping malformed target request: {}", e),
118            }
119        }
120
121        if let Some(has_target) = latest {
122            self.has_target = has_target;
123        }
124    }
125
126    /// Drain any pending completion messages - arrival of any message at all is the whole
127    /// signal, so the payload itself is never inspected. When target-tracking is enabled,
128    /// proactively clears `has_target` so this node converges to "idle" immediately.
129    fn poll_complete(&mut self) {
130        if self.target_request_topic.is_none() && self.complete {
131            return;
132        }
133        let Some(receiver) = self.complete_receiver.as_mut() else {
134            return;
135        };
136
137        if receiver.try_recv().is_ok() {
138            self.complete = true;
139            self.has_target = false;
140        }
141    }
142}
143
144#[async_trait]
145impl BehaviorNode for CapabilityTaskNode {
146    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
147        if self.complete_receiver.is_none() {
148            self.complete_receiver = Some(ctx.subscribe_raw::<Value>(&self.task_complete_topic).await?);
149        }
150        if let Some(target_request_topic) = self.target_request_topic.clone() {
151            if self.target_receiver.is_none() {
152                self.target_receiver = Some(ctx.subscribe_raw::<Value>(&target_request_topic).await?);
153            }
154        }
155
156        let status = if self.target_request_topic.is_some() {
157            self.poll_target();
158            self.poll_complete();
159            if self.has_target {
160                NodeStatus::Running
161            } else {
162                NodeStatus::Failure
163            }
164        } else {
165            self.poll_complete();
166            if self.complete {
167                NodeStatus::Success
168            } else {
169                NodeStatus::Running
170            }
171        };
172
173        self.last_status = Some(status);
174        Ok(status)
175    }
176
177    fn name(&self) -> &str {
178        "capability_task"
179    }
180
181    fn active_capability(&self) -> Option<&str> {
182        Some(self.capability.as_str())
183    }
184
185    fn reset_self(&mut self) {
186        self.complete = false;
187        self.has_target = false;
188    }
189
190    fn status_snapshot(&self) -> TreeStatusSnapshot {
191        TreeStatusSnapshot {
192            name: self.name().to_string(),
193            status: self.last_status,
194            active_capability: self.active_capability().map(String::from),
195            children: Vec::new(),
196        }
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use tokio::sync::mpsc;
204
205    /// Builds a node in the no-target-tracking ("rl_task"-equivalent) configuration.
206    fn task_only_node() -> (CapabilityTaskNode, mpsc::UnboundedSender<Value>) {
207        let (tx, rx) = mpsc::unbounded_channel();
208        (
209            CapabilityTaskNode::with_receivers("rl", "/rl/task_complete", None, Receiver::from(rx), None),
210            tx,
211        )
212    }
213
214    /// Builds a node in the target-tracking ("instance_track_task"-equivalent) configuration.
215    fn tracking_node() -> (
216        CapabilityTaskNode,
217        mpsc::UnboundedSender<Value>,
218        mpsc::UnboundedSender<Value>,
219    ) {
220        let (complete_tx, complete_rx) = mpsc::unbounded_channel();
221        let (target_tx, target_rx) = mpsc::unbounded_channel();
222        (
223            CapabilityTaskNode::with_receivers(
224                "instance-track",
225                "/instance-track/task_complete",
226                Some("/instance-track/target/request"),
227                Receiver::from(complete_rx),
228                Some(Receiver::from(target_rx)),
229            ),
230            complete_tx,
231            target_tx,
232        )
233    }
234
235    #[test]
236    fn declares_the_configured_capability() {
237        let (node, _tx) = task_only_node();
238        assert_eq!(node.active_capability(), Some("rl"));
239
240        let (node, _complete_tx, _target_tx) = tracking_node();
241        assert_eq!(node.active_capability(), Some("instance-track"));
242    }
243
244    #[test]
245    fn task_only_stays_incomplete_with_no_message() {
246        let (mut node, _tx) = task_only_node();
247        node.poll_complete();
248        assert!(!node.complete);
249    }
250
251    #[test]
252    fn task_only_completes_once_any_message_arrives() {
253        let (mut node, tx) = task_only_node();
254        tx.send(serde_json::json!({})).unwrap();
255
256        node.poll_complete();
257
258        assert!(node.complete);
259    }
260
261    #[test]
262    fn task_only_reset_self_clears_completion_for_a_later_pass() {
263        let (mut node, tx) = task_only_node();
264        tx.send(serde_json::json!({})).unwrap();
265        node.poll_complete();
266        assert!(node.complete);
267
268        node.reset_self();
269
270        assert!(!node.complete);
271    }
272
273    #[test]
274    fn tracking_no_target_ever_set_stays_without_a_target() {
275        let (mut node, _complete_tx, _target_tx) = tracking_node();
276        node.poll_target();
277        node.poll_complete();
278        assert!(!node.has_target);
279    }
280
281    #[test]
282    fn tracking_a_live_target_request_sets_has_target() {
283        let (mut node, _complete_tx, target_tx) = tracking_node();
284        target_tx
285            .send(serde_json::json!({ "bbox": { "x": 0.1, "y": 0.2, "width": 0.3, "height": 0.4 } }))
286            .unwrap();
287
288        node.poll_target();
289
290        assert!(node.has_target);
291    }
292
293    #[test]
294    fn tracking_a_clear_request_unsets_has_target_even_with_no_task_complete() {
295        let (mut node, _complete_tx, target_tx) = tracking_node();
296        target_tx
297            .send(serde_json::json!({ "bbox": { "x": 0.1, "y": 0.2, "width": 0.3, "height": 0.4 } }))
298            .unwrap();
299        node.poll_target();
300        assert!(node.has_target);
301
302        target_tx.send(serde_json::json!({ "bbox": null })).unwrap();
303        node.poll_target();
304
305        assert!(!node.has_target);
306    }
307
308    #[test]
309    fn tracking_task_complete_unsets_has_target_on_the_very_next_poll() {
310        let (mut node, complete_tx, target_tx) = tracking_node();
311        target_tx
312            .send(serde_json::json!({ "bbox": { "x": 0.1, "y": 0.2, "width": 0.3, "height": 0.4 } }))
313            .unwrap();
314        node.poll_target();
315        assert!(node.has_target);
316
317        complete_tx.send(serde_json::json!({})).unwrap();
318        node.poll_complete();
319
320        assert!(
321            !node.has_target,
322            "task_complete must converge has_target to false without waiting for a separate clear request"
323        );
324    }
325
326    #[test]
327    fn tracking_reset_self_clears_has_target_for_a_later_pass() {
328        let (mut node, _complete_tx, target_tx) = tracking_node();
329        target_tx
330            .send(serde_json::json!({ "bbox": { "x": 0.1, "y": 0.2, "width": 0.3, "height": 0.4 } }))
331            .unwrap();
332        node.poll_target();
333        assert!(node.has_target);
334
335        node.reset_self();
336
337        assert!(!node.has_target);
338    }
339
340    #[test]
341    fn from_json_requires_capability_and_task_complete_topic() {
342        let node = CapabilityTaskNode::from_json(serde_json::json!({
343            "capability": "rl",
344            "task_complete_topic": "/rl/task_complete"
345        }))
346        .unwrap();
347        assert_eq!(node.capability, "rl");
348        assert_eq!(node.task_complete_topic, "/rl/task_complete");
349        assert_eq!(node.target_request_topic, None);
350
351        assert!(CapabilityTaskNode::from_json(serde_json::json!({ "capability": "rl" })).is_err());
352        assert!(CapabilityTaskNode::from_json(serde_json::json!({})).is_err());
353    }
354
355    #[test]
356    fn from_json_reads_optional_target_request_topic() {
357        let node = CapabilityTaskNode::from_json(serde_json::json!({
358            "capability": "instance-track",
359            "task_complete_topic": "/instance-track/task_complete",
360            "target_request_topic": "/instance-track/target/request"
361        }))
362        .unwrap();
363        assert_eq!(node.capability, "instance-track");
364        assert_eq!(
365            node.target_request_topic,
366            Some("/instance-track/target/request".to_string())
367        );
368    }
369}