Skip to main content

mecha10_behavior_runtime/
composition.rs

1//! Composition primitives for building behavior trees
2//!
3//! This module provides the fundamental composition nodes: Sequence, Selector, and Parallel,
4//! plus the single-child decorator nodes (`Inverter`, `ForceSuccess`, `ForceFailure`, `Repeat`,
5//! `Retry`, `Timeout`) that wrap and modify one child's result without themselves branching.
6
7use crate::{BehaviorNode, BoxedBehavior, NodeStatus};
8use async_trait::async_trait;
9use mecha10_core::Context;
10use std::time::{Duration, Instant};
11use tracing::{debug, info, warn};
12
13#[derive(Debug)]
14pub struct SequenceNode {
15    children: Vec<BoxedBehavior>,
16    current_index: usize,
17}
18
19impl SequenceNode {
20    /// Create a new sequence node with the given children.
21    pub fn new(children: Vec<BoxedBehavior>) -> Self {
22        Self {
23            children,
24            current_index: 0,
25        }
26    }
27
28    /// Add a child behavior to the sequence.
29    pub fn add_child(&mut self, child: BoxedBehavior) {
30        self.children.push(child);
31    }
32
33    /// Get the number of children.
34    pub fn len(&self) -> usize {
35        self.children.len()
36    }
37
38    /// Check if the sequence is empty.
39    pub fn is_empty(&self) -> bool {
40        self.children.is_empty()
41    }
42}
43
44#[async_trait]
45impl BehaviorNode for SequenceNode {
46    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
47        if self.children.is_empty() {
48            return Ok(NodeStatus::Success);
49        }
50
51        let total_children = self.children.len();
52        while self.current_index < total_children {
53            debug!(
54                "Sequence: ticking child {} of {}",
55                self.current_index + 1,
56                total_children
57            );
58
59            let child = &mut self.children[self.current_index];
60            match child.tick(ctx).await? {
61                NodeStatus::Success => {
62                    self.current_index += 1;
63                }
64                NodeStatus::Failure => {
65                    debug!("Sequence: child {} failed", self.current_index + 1);
66                    return Ok(NodeStatus::Failure);
67                }
68                NodeStatus::Running => {
69                    return Ok(NodeStatus::Running);
70                }
71            }
72        }
73
74        debug!("Sequence: all children succeeded");
75        Ok(NodeStatus::Success)
76    }
77
78    crate::reset_index_field!(current_index);
79
80    fn name(&self) -> &str {
81        "sequence"
82    }
83
84    fn children_mut(&mut self) -> Vec<&mut dyn BehaviorNode> {
85        crate::boxed_children_mut(self.children.iter_mut())
86    }
87
88    fn active_capability(&self) -> Option<&str> {
89        self.children
90            .get(self.current_index)
91            .and_then(|c| c.active_capability())
92    }
93}
94
95#[derive(Debug)]
96pub struct SelectOrNode {
97    children: Vec<BoxedBehavior>,
98    current_index: usize,
99}
100
101impl SelectOrNode {
102    /// Create a new selector node with the given children.
103    pub fn new(children: Vec<BoxedBehavior>) -> Self {
104        Self {
105            children,
106            current_index: 0,
107        }
108    }
109
110    /// Add a child behavior to the selector.
111    pub fn add_child(&mut self, child: BoxedBehavior) {
112        self.children.push(child);
113    }
114
115    /// Get the number of children.
116    pub fn len(&self) -> usize {
117        self.children.len()
118    }
119
120    /// Check if the selector is empty.
121    pub fn is_empty(&self) -> bool {
122        self.children.is_empty()
123    }
124}
125
126#[async_trait]
127impl BehaviorNode for SelectOrNode {
128    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
129        if self.children.is_empty() {
130            return Ok(NodeStatus::Failure);
131        }
132
133        let total_children = self.children.len();
134        while self.current_index < total_children {
135            debug!(
136                "Selector: ticking child {} of {}",
137                self.current_index + 1,
138                total_children
139            );
140
141            let child = &mut self.children[self.current_index];
142            match child.tick(ctx).await? {
143                NodeStatus::Success => {
144                    debug!("Selector: child {} succeeded", self.current_index + 1);
145                    return Ok(NodeStatus::Success);
146                }
147                NodeStatus::Failure => {
148                    warn!("Selector: child {} failed, trying next", self.current_index + 1);
149                    self.current_index += 1;
150                }
151                NodeStatus::Running => {
152                    return Ok(NodeStatus::Running);
153                }
154            }
155        }
156
157        debug!("Selector: all children failed");
158        Ok(NodeStatus::Failure)
159    }
160
161    crate::reset_index_field!(current_index);
162
163    fn name(&self) -> &str {
164        "selector"
165    }
166
167    fn children_mut(&mut self) -> Vec<&mut dyn BehaviorNode> {
168        crate::boxed_children_mut(self.children.iter_mut())
169    }
170
171    fn active_capability(&self) -> Option<&str> {
172        self.children
173            .get(self.current_index)
174            .and_then(|c| c.active_capability())
175    }
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub enum ParallelPolicy {
180    RequireAll,
181
182    RequireOne,
183
184    RequireN(usize),
185}
186
187#[derive(Debug)]
188pub struct ParallelNode {
189    children: Vec<BoxedBehavior>,
190    policy: ParallelPolicy,
191    child_statuses: Vec<NodeStatus>,
192}
193
194impl ParallelNode {
195    /// Create a new parallel node with the given children and policy.
196    pub fn new(children: Vec<BoxedBehavior>, policy: ParallelPolicy) -> Self {
197        let child_statuses = vec![NodeStatus::Running; children.len()];
198        Self {
199            children,
200            policy,
201            child_statuses,
202        }
203    }
204
205    /// Add a child behavior to the parallel node.
206    pub fn add_child(&mut self, child: BoxedBehavior) {
207        self.children.push(child);
208        self.child_statuses.push(NodeStatus::Running);
209    }
210
211    /// Get the number of children.
212    pub fn len(&self) -> usize {
213        self.children.len()
214    }
215
216    /// Check if the parallel node is empty.
217    pub fn is_empty(&self) -> bool {
218        self.children.is_empty()
219    }
220}
221
222#[async_trait]
223impl BehaviorNode for ParallelNode {
224    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
225        if self.children.is_empty() {
226            return Ok(NodeStatus::Success);
227        }
228
229        for (i, child) in self.children.iter_mut().enumerate() {
230            if self.child_statuses[i].is_running() {
231                let status = child.tick(ctx).await?;
232                self.child_statuses[i] = status;
233                debug!("Parallel: child {} status: {}", i + 1, status);
234            }
235        }
236
237        let success_count = self.child_statuses.iter().filter(|s| s.is_success()).count();
238        let failure_count = self.child_statuses.iter().filter(|s| s.is_failure()).count();
239        let _running_count = self.child_statuses.iter().filter(|s| s.is_running()).count();
240
241        match self.policy {
242            ParallelPolicy::RequireAll => {
243                if failure_count > 0 {
244                    debug!("Parallel (RequireAll): at least one child failed");
245                    Ok(NodeStatus::Failure)
246                } else if success_count == self.children.len() {
247                    debug!("Parallel (RequireAll): all children succeeded");
248                    Ok(NodeStatus::Success)
249                } else {
250                    Ok(NodeStatus::Running)
251                }
252            }
253            ParallelPolicy::RequireOne => {
254                if success_count > 0 {
255                    debug!("Parallel (RequireOne): at least one child succeeded");
256                    Ok(NodeStatus::Success)
257                } else if failure_count == self.children.len() {
258                    debug!("Parallel (RequireOne): all children failed");
259                    Ok(NodeStatus::Failure)
260                } else {
261                    Ok(NodeStatus::Running)
262                }
263            }
264            ParallelPolicy::RequireN(n) => {
265                if success_count >= n {
266                    debug!("Parallel (RequireN({})): {} children succeeded", n, success_count);
267                    Ok(NodeStatus::Success)
268                } else if (self.children.len() - failure_count) < n {
269                    debug!("Parallel (RequireN({})): not enough children can succeed", n);
270                    Ok(NodeStatus::Failure)
271                } else {
272                    Ok(NodeStatus::Running)
273                }
274            }
275        }
276    }
277
278    crate::reset_child_statuses_field!(child_statuses);
279
280    fn name(&self) -> &str {
281        "parallel"
282    }
283
284    fn children_mut(&mut self) -> Vec<&mut dyn BehaviorNode> {
285        crate::boxed_children_mut(self.children.iter_mut())
286    }
287
288    /// The first still-`Running` child's capability, if any - every running child is actively
289    /// ticking concurrently (unlike `Sequence`/`Selector`'s single current child), but at most
290    /// one is expected to declare a capability in practice, so "first" is an arbitrary but
291    /// deterministic tie-break rather than a real policy choice.
292    fn active_capability(&self) -> Option<&str> {
293        self.children
294            .iter()
295            .zip(self.child_statuses.iter())
296            .find(|(_, status)| status.is_running())
297            .and_then(|(child, _)| child.active_capability())
298    }
299}
300
301/// Like `ParallelNode`, but with no memory of a prior resolution: every child is ticked, every
302/// call, unconditionally. `ParallelNode` freezes a child once it resolves to non-`Running` (via
303/// `child_statuses`) until an external `reset()` - correct for "run these concurrent subtasks to
304/// completion", wrong for an interrupt-condition layer that must be re-evaluated indefinitely
305/// (the operator may click, clear, click again, many times over a session).
306#[derive(Debug)]
307pub struct WatchNode {
308    children: Vec<BoxedBehavior>,
309    policy: ParallelPolicy,
310    last_active: Option<usize>,
311}
312
313impl WatchNode {
314    /// Create a new watch node with the given children and policy.
315    pub fn new(children: Vec<BoxedBehavior>, policy: ParallelPolicy) -> Self {
316        Self {
317            children,
318            policy,
319            last_active: None,
320        }
321    }
322
323    /// Add a child behavior to the watch node.
324    pub fn add_child(&mut self, child: BoxedBehavior) {
325        self.children.push(child);
326    }
327
328    /// Get the number of children.
329    pub fn len(&self) -> usize {
330        self.children.len()
331    }
332
333    /// Check if the watch node is empty.
334    pub fn is_empty(&self) -> bool {
335        self.children.is_empty()
336    }
337}
338
339#[async_trait]
340impl BehaviorNode for WatchNode {
341    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
342        if self.children.is_empty() {
343            self.last_active = None;
344            return Ok(NodeStatus::Failure);
345        }
346
347        let mut statuses = Vec::with_capacity(self.children.len());
348        for child in &mut self.children {
349            statuses.push(child.tick(ctx).await?);
350        }
351
352        self.last_active = statuses.iter().position(|s| s.is_running());
353
354        let success_count = statuses.iter().filter(|s| s.is_success()).count();
355        let failure_count = statuses.iter().filter(|s| s.is_failure()).count();
356
357        match self.policy {
358            ParallelPolicy::RequireAll => {
359                if failure_count > 0 {
360                    debug!("Watch (RequireAll): at least one child failed");
361                    Ok(NodeStatus::Failure)
362                } else if success_count == self.children.len() {
363                    debug!("Watch (RequireAll): all children succeeded");
364                    Ok(NodeStatus::Success)
365                } else {
366                    Ok(NodeStatus::Running)
367                }
368            }
369            ParallelPolicy::RequireOne => {
370                if success_count > 0 {
371                    debug!("Watch (RequireOne): at least one child succeeded");
372                    Ok(NodeStatus::Success)
373                } else if failure_count == self.children.len() {
374                    debug!("Watch (RequireOne): all children failed");
375                    Ok(NodeStatus::Failure)
376                } else {
377                    Ok(NodeStatus::Running)
378                }
379            }
380            ParallelPolicy::RequireN(n) => {
381                if success_count >= n {
382                    debug!("Watch (RequireN({})): {} children succeeded", n, success_count);
383                    Ok(NodeStatus::Success)
384                } else if (self.children.len() - failure_count) < n {
385                    debug!("Watch (RequireN({})): not enough children can succeed", n);
386                    Ok(NodeStatus::Failure)
387                } else {
388                    Ok(NodeStatus::Running)
389                }
390            }
391        }
392    }
393
394    fn name(&self) -> &str {
395        "watch"
396    }
397
398    fn children_mut(&mut self) -> Vec<&mut dyn BehaviorNode> {
399        crate::boxed_children_mut(self.children.iter_mut())
400    }
401
402    /// Unlike Sequence/Selector/Parallel (which track a "current" child via a persisted index or
403    /// per-child statuses), every child here is ticked every time with no persisted statuses to
404    /// read after the fact - so `tick()` separately records which child (if any) resolved
405    /// `Running` *this* call into `last_active`, and this just forwards to that child.
406    fn active_capability(&self) -> Option<&str> {
407        self.last_active
408            .and_then(|i| self.children.get(i))
409            .and_then(|child| child.active_capability())
410    }
411}
412
413/// A single priority layer in a [`SubsumptionNode`]: a priority (higher wins) plus the behavior
414/// that implements it.
415#[derive(Debug)]
416pub struct SubsumptionLayer {
417    pub priority: u8,
418    pub behavior: BoxedBehavior,
419    pub name: Option<String>,
420}
421
422impl SubsumptionLayer {
423    mecha10_core::new_with_defaults!(
424        /// Create a new subsumption layer.
425        pub fn new(priority: u8, behavior: BoxedBehavior) -> Self {
426            name: None
427        }
428    );
429
430    mecha10_core::builder_setter!(into_some, with_name, name, impl Into<String>);
431}
432
433/// Priority-based preemption (subsumption architecture): on each tick, tries each layer
434/// highest-priority first. The first layer whose `tick()` returns `Running`/`Success` "wins" and
435/// its status is returned for that tick - lower-priority layers aren't ticked at all that cycle,
436/// so their own internal progress (e.g. a `SequenceNode`'s current child index) is naturally
437/// preserved rather than reset, which is what gives a preempted lower layer "resume where it left
438/// off" semantics for free.
439#[derive(Debug)]
440pub struct SubsumptionNode {
441    pub layers: Vec<SubsumptionLayer>,
442    last_winning_layer: Option<usize>,
443}
444
445impl SubsumptionNode {
446    /// Create a new empty subsumption node.
447    pub fn new() -> Self {
448        Self {
449            layers: Vec::new(),
450            last_winning_layer: None,
451        }
452    }
453
454    /// Add a layer with the given priority and behavior.
455    ///
456    /// Layers are automatically sorted by priority (highest first).
457    pub fn add_layer(mut self, priority: u8, behavior: BoxedBehavior) -> Self {
458        self.layers.push(SubsumptionLayer::new(priority, behavior));
459        self.layers.sort_by_key(|b| std::cmp::Reverse(b.priority));
460        self
461    }
462}
463
464impl Default for SubsumptionNode {
465    fn default() -> Self {
466        Self::new()
467    }
468}
469
470#[async_trait]
471impl BehaviorNode for SubsumptionNode {
472    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
473        if self.layers.is_empty() {
474            self.last_winning_layer = None;
475            return Ok(NodeStatus::Failure);
476        }
477
478        for (i, layer) in self.layers.iter_mut().enumerate() {
479            let layer_name = layer.name.as_deref().unwrap_or("unnamed");
480
481            debug!(
482                "Subsumption: trying layer '{}' (priority {})",
483                layer_name, layer.priority
484            );
485
486            let status = layer.behavior.tick(ctx).await?;
487
488            match status {
489                NodeStatus::Success | NodeStatus::Running => {
490                    info!(
491                        "Subsumption: layer '{}' (priority {}) taking control with status {}",
492                        layer_name, layer.priority, status
493                    );
494                    self.last_winning_layer = Some(i);
495                    return Ok(status);
496                }
497                NodeStatus::Failure => {
498                    debug!("Subsumption: layer '{}' failed, trying next layer", layer_name);
499                    continue;
500                }
501            }
502        }
503
504        debug!("Subsumption: all layers failed");
505        self.last_winning_layer = None;
506        Ok(NodeStatus::Failure)
507    }
508
509    fn name(&self) -> &str {
510        "subsumption"
511    }
512
513    crate::children_mut_via_wrapped_behavior!(layers, layer);
514
515    /// Forwards the winning layer's capability (the highest-priority layer whose `tick()`
516    /// returned `Running`/`Success` this call) - `None` if every layer failed. Needs its own
517    /// tracked state (`last_winning_layer`, set inside `tick()`) since, unlike `SequenceNode`'s
518    /// `current_index`, "which layer won" isn't otherwise recoverable after `tick()` returns.
519    fn active_capability(&self) -> Option<&str> {
520        self.last_winning_layer
521            .and_then(|i| self.layers.get(i))
522            .and_then(|layer| layer.behavior.active_capability())
523    }
524}
525
526/// Negates a `Success`/`Failure` result from its single child; `Running` passes through
527/// unchanged. The canonical decorator for turning a "condition" leaf/subtree into its opposite
528/// (e.g. "target NOT in range") without duplicating the underlying check.
529#[derive(Debug)]
530pub struct InverterNode {
531    child: BoxedBehavior,
532}
533
534impl InverterNode {
535    /// Create a new inverter node wrapping `child`.
536    pub fn new(child: BoxedBehavior) -> Self {
537        Self { child }
538    }
539}
540
541#[async_trait]
542impl BehaviorNode for InverterNode {
543    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
544        Ok(match self.child.tick(ctx).await? {
545            NodeStatus::Success => NodeStatus::Failure,
546            NodeStatus::Failure => NodeStatus::Success,
547            NodeStatus::Running => NodeStatus::Running,
548        })
549    }
550
551    fn name(&self) -> &str {
552        "inverter"
553    }
554
555    fn children_mut(&mut self) -> Vec<&mut dyn BehaviorNode> {
556        vec![self.child.as_mut()]
557    }
558
559    fn active_capability(&self) -> Option<&str> {
560        self.child.active_capability()
561    }
562}
563
564/// Always reports `Success` once its child resolves, regardless of whether the child actually
565/// succeeded or failed; `Running` passes through unchanged. Useful for an optional step in a
566/// `Sequence` whose failure shouldn't abort the rest of the sequence (e.g. a best-effort log/
567/// notify action).
568#[derive(Debug)]
569pub struct ForceSuccessNode {
570    child: BoxedBehavior,
571}
572
573impl ForceSuccessNode {
574    /// Create a new force-success node wrapping `child`.
575    pub fn new(child: BoxedBehavior) -> Self {
576        Self { child }
577    }
578}
579
580#[async_trait]
581impl BehaviorNode for ForceSuccessNode {
582    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
583        Ok(match self.child.tick(ctx).await? {
584            NodeStatus::Running => NodeStatus::Running,
585            NodeStatus::Success | NodeStatus::Failure => NodeStatus::Success,
586        })
587    }
588
589    fn name(&self) -> &str {
590        "force_success"
591    }
592
593    fn children_mut(&mut self) -> Vec<&mut dyn BehaviorNode> {
594        vec![self.child.as_mut()]
595    }
596
597    fn active_capability(&self) -> Option<&str> {
598        self.child.active_capability()
599    }
600}
601
602/// Always reports `Failure` once its child resolves, regardless of whether the child actually
603/// succeeded or failed; `Running` passes through unchanged. The mirror of `ForceSuccessNode` -
604/// useful inside a `Selector` to force a fallback to try the next option even after this one
605/// "succeeded" in some advisory sense.
606#[derive(Debug)]
607pub struct ForceFailureNode {
608    child: BoxedBehavior,
609}
610
611impl ForceFailureNode {
612    /// Create a new force-failure node wrapping `child`.
613    pub fn new(child: BoxedBehavior) -> Self {
614        Self { child }
615    }
616}
617
618#[async_trait]
619impl BehaviorNode for ForceFailureNode {
620    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
621        Ok(match self.child.tick(ctx).await? {
622            NodeStatus::Running => NodeStatus::Running,
623            NodeStatus::Success | NodeStatus::Failure => NodeStatus::Failure,
624        })
625    }
626
627    fn name(&self) -> &str {
628        "force_failure"
629    }
630
631    fn children_mut(&mut self) -> Vec<&mut dyn BehaviorNode> {
632        vec![self.child.as_mut()]
633    }
634
635    fn active_capability(&self) -> Option<&str> {
636        self.child.active_capability()
637    }
638}
639
640/// Re-runs its child every time it succeeds, up to an optional fixed count; a `Failure` from the
641/// child always ends the loop immediately with `Failure`. `count: None` repeats indefinitely
642/// (the node only ever stops via the child failing) - e.g. a patrol leg re-run forever until
643/// something fails it.
644#[derive(Debug)]
645pub struct RepeatNode {
646    child: BoxedBehavior,
647    count: Option<u32>,
648    completed: u32,
649}
650
651impl RepeatNode {
652    /// Create a new repeat node wrapping `child`. `count: None` repeats indefinitely.
653    pub fn new(child: BoxedBehavior, count: Option<u32>) -> Self {
654        Self {
655            child,
656            count,
657            completed: 0,
658        }
659    }
660}
661
662#[async_trait]
663impl BehaviorNode for RepeatNode {
664    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
665        if let Some(count) = self.count {
666            if self.completed >= count {
667                return Ok(NodeStatus::Success);
668            }
669        }
670
671        match self.child.tick(ctx).await? {
672            NodeStatus::Success => {
673                self.completed += 1;
674                self.child.reset().await?;
675
676                match self.count {
677                    Some(count) if self.completed >= count => Ok(NodeStatus::Success),
678                    _ => Ok(NodeStatus::Running),
679                }
680            }
681            NodeStatus::Failure => Ok(NodeStatus::Failure),
682            NodeStatus::Running => Ok(NodeStatus::Running),
683        }
684    }
685
686    fn reset_self(&mut self) {
687        self.completed = 0;
688    }
689
690    fn name(&self) -> &str {
691        "repeat"
692    }
693
694    fn children_mut(&mut self) -> Vec<&mut dyn BehaviorNode> {
695        vec![self.child.as_mut()]
696    }
697
698    fn active_capability(&self) -> Option<&str> {
699        self.child.active_capability()
700    }
701}
702
703/// Re-runs its child up to `max_attempts` times whenever it fails, succeeding as soon as one
704/// attempt succeeds; only reports `Failure` once every attempt has been exhausted. The
705/// complement of `RepeatNode` (which loops on success) - for flaky/best-effort actions like a
706/// detection scan that may need a few tries (e.g. `capture-target`'s "take a picture of a
707/// uniquely-found target").
708#[derive(Debug)]
709pub struct RetryNode {
710    child: BoxedBehavior,
711    max_attempts: u32,
712    attempts: u32,
713}
714
715impl RetryNode {
716    /// Create a new retry node wrapping `child`, allowing up to `max_attempts` tries (must be at
717    /// least 1).
718    pub fn new(child: BoxedBehavior, max_attempts: u32) -> Self {
719        Self {
720            child,
721            max_attempts: max_attempts.max(1),
722            attempts: 0,
723        }
724    }
725}
726
727#[async_trait]
728impl BehaviorNode for RetryNode {
729    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
730        match self.child.tick(ctx).await? {
731            NodeStatus::Success => Ok(NodeStatus::Success),
732            NodeStatus::Running => Ok(NodeStatus::Running),
733            NodeStatus::Failure => {
734                self.attempts += 1;
735                if self.attempts >= self.max_attempts {
736                    debug!("Retry: child failed after {} attempt(s), giving up", self.attempts);
737                    Ok(NodeStatus::Failure)
738                } else {
739                    debug!(
740                        "Retry: child failed on attempt {} of {}, retrying",
741                        self.attempts, self.max_attempts
742                    );
743                    self.child.reset().await?;
744                    Ok(NodeStatus::Running)
745                }
746            }
747        }
748    }
749
750    fn reset_self(&mut self) {
751        self.attempts = 0;
752    }
753
754    fn name(&self) -> &str {
755        "retry"
756    }
757
758    fn children_mut(&mut self) -> Vec<&mut dyn BehaviorNode> {
759        vec![self.child.as_mut()]
760    }
761
762    fn active_capability(&self) -> Option<&str> {
763        self.child.active_capability()
764    }
765}
766
767/// Bounds how long its child may stay `Running`: starts a clock on the first tick after
768/// construction/reset, and once `duration` has elapsed without the child resolving, immediately
769/// reports `Failure` without ticking the child again that call (and resets the child's own state,
770/// since it was abandoned mid-flight). A child that resolves (`Success`/`Failure`) before the
771/// deadline passes its status straight through. Needed for any "give up after N seconds" bound,
772/// e.g. `track-object`'s "follows it until out of range".
773#[derive(Debug)]
774pub struct TimeoutNode {
775    child: BoxedBehavior,
776    duration: Duration,
777    start_time: Option<Instant>,
778}
779
780impl TimeoutNode {
781    /// Create a new timeout node wrapping `child`, bounding it to `duration`.
782    pub fn new(child: BoxedBehavior, duration: Duration) -> Self {
783        Self {
784            child,
785            duration,
786            start_time: None,
787        }
788    }
789}
790
791#[async_trait]
792impl BehaviorNode for TimeoutNode {
793    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
794        let now = Instant::now();
795        let start = *self.start_time.get_or_insert(now);
796
797        if now.duration_since(start) >= self.duration {
798            warn!("Timeout: child did not resolve within {:?}, failing", self.duration);
799            self.start_time = None;
800            self.child.reset().await?;
801            return Ok(NodeStatus::Failure);
802        }
803
804        let status = self.child.tick(ctx).await?;
805        if status.is_complete() {
806            self.start_time = None;
807        }
808        Ok(status)
809    }
810
811    fn reset_self(&mut self) {
812        self.start_time = None;
813    }
814
815    fn name(&self) -> &str {
816        "timeout"
817    }
818
819    fn children_mut(&mut self) -> Vec<&mut dyn BehaviorNode> {
820        vec![self.child.as_mut()]
821    }
822
823    fn active_capability(&self) -> Option<&str> {
824        self.child.active_capability()
825    }
826}
827
828#[cfg(test)]
829mod tests {
830    use super::*;
831
832    #[derive(Debug, Default)]
833    struct DummyBehavior;
834
835    #[async_trait]
836    impl BehaviorNode for DummyBehavior {
837        async fn tick(&mut self, _ctx: &Context) -> anyhow::Result<NodeStatus> {
838            unimplemented!("not exercised by these tests")
839        }
840    }
841
842    #[derive(Debug)]
843    struct CapabilityBehavior(&'static str);
844
845    #[async_trait]
846    impl BehaviorNode for CapabilityBehavior {
847        async fn tick(&mut self, _ctx: &Context) -> anyhow::Result<NodeStatus> {
848            unimplemented!("not exercised by these tests")
849        }
850
851        fn active_capability(&self) -> Option<&str> {
852            Some(self.0)
853        }
854    }
855
856    #[test]
857    fn sequence_children_mut_exposes_every_child() {
858        let mut node = SequenceNode::new(vec![Box::new(DummyBehavior), Box::new(DummyBehavior)]);
859        assert_eq!(node.children_mut().len(), 2);
860    }
861
862    #[tokio::test]
863    async fn sequence_reset_rewinds_current_index() {
864        let mut node = SequenceNode::new(vec![Box::new(DummyBehavior)]);
865        node.current_index = 1;
866
867        node.reset().await.unwrap();
868
869        assert_eq!(node.current_index, 0);
870    }
871
872    #[test]
873    fn selector_children_mut_exposes_every_child() {
874        let mut node = SelectOrNode::new(vec![Box::new(DummyBehavior), Box::new(DummyBehavior)]);
875        assert_eq!(node.children_mut().len(), 2);
876    }
877
878    #[tokio::test]
879    async fn selector_reset_rewinds_current_index() {
880        let mut node = SelectOrNode::new(vec![Box::new(DummyBehavior)]);
881        node.current_index = 1;
882
883        node.reset().await.unwrap();
884
885        assert_eq!(node.current_index, 0);
886    }
887
888    #[test]
889    fn parallel_children_mut_exposes_every_child() {
890        let mut node = ParallelNode::new(
891            vec![Box::new(DummyBehavior), Box::new(DummyBehavior)],
892            ParallelPolicy::RequireAll,
893        );
894        assert_eq!(node.children_mut().len(), 2);
895    }
896
897    #[tokio::test]
898    async fn parallel_reset_clears_child_statuses_back_to_running() {
899        let mut node = ParallelNode::new(vec![Box::new(DummyBehavior)], ParallelPolicy::RequireAll);
900        node.child_statuses[0] = NodeStatus::Success;
901
902        node.reset().await.unwrap();
903
904        assert_eq!(node.child_statuses[0], NodeStatus::Running);
905    }
906
907    #[test]
908    fn sequence_forwards_the_current_childs_active_capability() {
909        let node = SequenceNode::new(vec![Box::new(CapabilityBehavior("rl")), Box::new(DummyBehavior)]);
910        assert_eq!(node.active_capability(), Some("rl"));
911    }
912
913    #[test]
914    fn sequence_has_no_active_capability_once_past_the_declaring_child() {
915        let mut node = SequenceNode::new(vec![Box::new(CapabilityBehavior("rl")), Box::new(DummyBehavior)]);
916        node.current_index = 1;
917        assert_eq!(node.active_capability(), None);
918    }
919
920    #[test]
921    fn selector_forwards_the_current_childs_active_capability() {
922        let node = SelectOrNode::new(vec![Box::new(CapabilityBehavior("rl")), Box::new(DummyBehavior)]);
923        assert_eq!(node.active_capability(), Some("rl"));
924    }
925
926    #[test]
927    fn parallel_forwards_the_first_running_childs_active_capability() {
928        let mut node = ParallelNode::new(
929            vec![Box::new(CapabilityBehavior("rl")), Box::new(DummyBehavior)],
930            ParallelPolicy::RequireAll,
931        );
932        node.child_statuses[0] = NodeStatus::Success;
933        assert_eq!(
934            node.active_capability(),
935            None,
936            "a non-running child's capability must not be reported"
937        );
938    }
939
940    /// A child whose status changes from tick to tick (e.g. a target that comes and goes), used
941    /// to prove `WatchNode` re-evaluates every call instead of freezing like `ParallelNode`.
942    #[derive(Debug)]
943    struct SequencedStatusBehavior {
944        statuses: Vec<NodeStatus>,
945        index: usize,
946        tick_count: usize,
947        capability: Option<&'static str>,
948    }
949
950    impl SequencedStatusBehavior {
951        fn new(statuses: Vec<NodeStatus>) -> Self {
952            Self {
953                statuses,
954                index: 0,
955                tick_count: 0,
956                capability: None,
957            }
958        }
959
960        fn with_capability(statuses: Vec<NodeStatus>, capability: &'static str) -> Self {
961            Self {
962                capability: Some(capability),
963                ..Self::new(statuses)
964            }
965        }
966    }
967
968    #[async_trait]
969    impl BehaviorNode for SequencedStatusBehavior {
970        async fn tick(&mut self, _ctx: &Context) -> anyhow::Result<NodeStatus> {
971            self.tick_count += 1;
972            let status = self.statuses[self.index.min(self.statuses.len() - 1)];
973            if self.index + 1 < self.statuses.len() {
974                self.index += 1;
975            }
976            Ok(status)
977        }
978
979        fn active_capability(&self) -> Option<&str> {
980            self.capability
981        }
982    }
983
984    #[test]
985    fn watch_children_mut_exposes_every_child() {
986        let mut node = WatchNode::new(
987            vec![Box::new(DummyBehavior), Box::new(DummyBehavior)],
988            ParallelPolicy::RequireOne,
989        );
990        assert_eq!(node.children_mut().len(), 2);
991    }
992
993    #[tokio::test]
994    #[ignore = "Requires Redis infrastructure"]
995    async fn watch_is_empty_returns_failure() {
996        let ctx = crate::test_helpers::create_test_context("watch_empty").await;
997        let mut node = WatchNode::new(vec![], ParallelPolicy::RequireOne);
998        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Failure);
999    }
1000
1001    /// Regression test: unlike `ParallelNode`, `WatchNode` has no `child_statuses` memory, so a
1002    /// child that flips from `Failure` to `Running` across ticks must be re-ticked every call and
1003    /// the aggregate status must reflect *that* tick's actual child statuses, not a stale one.
1004    #[tokio::test]
1005    #[ignore = "Requires Redis infrastructure"]
1006    async fn watch_re_ticks_every_child_every_call_and_reflects_the_latest_statuses() {
1007        let ctx = crate::test_helpers::create_test_context("watch_re_ticks").await;
1008        let mut node = WatchNode::new(
1009            vec![Box::new(SequencedStatusBehavior::new(vec![
1010                NodeStatus::Failure,
1011                NodeStatus::Running,
1012                NodeStatus::Failure,
1013            ]))],
1014            ParallelPolicy::RequireOne,
1015        );
1016
1017        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Failure);
1018        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Running);
1019        assert_eq!(
1020            node.tick(&ctx).await.unwrap(),
1021            NodeStatus::Failure,
1022            "a ParallelNode would have frozen on the first Failure and never re-ticked the child"
1023        );
1024    }
1025
1026    #[tokio::test]
1027    #[ignore = "Requires Redis infrastructure"]
1028    async fn watch_forwards_the_currently_running_childs_active_capability() {
1029        let ctx = crate::test_helpers::create_test_context("watch_capability").await;
1030        let mut node = WatchNode::new(
1031            vec![Box::new(SequencedStatusBehavior::with_capability(
1032                vec![NodeStatus::Running, NodeStatus::Failure],
1033                "instance-track",
1034            ))],
1035            ParallelPolicy::RequireOne,
1036        );
1037
1038        node.tick(&ctx).await.unwrap();
1039        assert_eq!(node.active_capability(), Some("instance-track"));
1040
1041        node.tick(&ctx).await.unwrap();
1042        assert_eq!(
1043            node.active_capability(),
1044            None,
1045            "a child that was running last tick and is not running this tick must not keep reporting its capability"
1046        );
1047    }
1048
1049    #[test]
1050    fn subsumption_children_mut_exposes_every_layer_behavior() {
1051        let mut node = SubsumptionNode::new()
1052            .add_layer(10, Box::new(DummyBehavior))
1053            .add_layer(5, Box::new(DummyBehavior));
1054        assert_eq!(node.children_mut().len(), 2);
1055    }
1056
1057    /// A double that both returns a fixed `NodeStatus` from `tick()` and declares a capability -
1058    /// `CapabilityBehavior` above only does the latter (its `tick()` is `unimplemented!()`), so
1059    /// it can't be used to exercise `SubsumptionNode::active_capability()` end-to-end through an
1060    /// actual `tick()` call.
1061    #[derive(Debug)]
1062    struct StatusAndCapabilityBehavior {
1063        status: NodeStatus,
1064        capability: &'static str,
1065    }
1066
1067    #[async_trait]
1068    impl BehaviorNode for StatusAndCapabilityBehavior {
1069        async fn tick(&mut self, _ctx: &Context) -> anyhow::Result<NodeStatus> {
1070            Ok(self.status)
1071        }
1072
1073        fn active_capability(&self) -> Option<&str> {
1074            Some(self.capability)
1075        }
1076    }
1077
1078    #[tokio::test]
1079    #[ignore = "Requires Redis infrastructure"]
1080    async fn subsumption_active_capability_forwards_the_winning_layers_capability() {
1081        let ctx = crate::test_helpers::create_test_context("subsumption_capability").await;
1082        let mut node = SubsumptionNode::new()
1083            .add_layer(
1084                10,
1085                Box::new(StatusAndCapabilityBehavior {
1086                    status: NodeStatus::Failure,
1087                    capability: "instance-track",
1088                }),
1089            )
1090            .add_layer(
1091                0,
1092                Box::new(StatusAndCapabilityBehavior {
1093                    status: NodeStatus::Running,
1094                    capability: "idle-wander",
1095                }),
1096            );
1097
1098        assert_eq!(node.active_capability(), None, "no tick has happened yet");
1099
1100        let status = node.tick(&ctx).await.unwrap();
1101        assert_eq!(
1102            status,
1103            NodeStatus::Running,
1104            "the high-priority layer fails, so the low layer wins"
1105        );
1106        assert_eq!(
1107            node.active_capability(),
1108            Some("idle-wander"),
1109            "active_capability must forward the winning (low-priority) layer's capability, not the failed high-priority one"
1110        );
1111    }
1112
1113    #[tokio::test]
1114    #[ignore = "Requires Redis infrastructure"]
1115    async fn subsumption_active_capability_none_when_all_layers_fail() {
1116        let ctx = crate::test_helpers::create_test_context("subsumption_capability_all_fail").await;
1117        let mut node =
1118            SubsumptionNode::new().add_layer(10, Box::new(SequencedStatusBehavior::new(vec![NodeStatus::Failure])));
1119
1120        node.tick(&ctx).await.unwrap();
1121        assert_eq!(node.active_capability(), None);
1122    }
1123
1124    /// A stub mimicking `InstanceTrackTaskNode`/`SetInstanceTrackTargetNode`'s new
1125    /// Failure-when-idle/Running-when-engaged convergence (see their module docs), controllable
1126    /// from the test via a shared flag - zero `Context`/messaging dependency, same style as
1127    /// `DummyBehavior`/`CapabilityBehavior` above.
1128    #[derive(Debug)]
1129    struct ToggleableBehavior {
1130        engaged: std::sync::Arc<std::sync::atomic::AtomicBool>,
1131    }
1132
1133    #[async_trait]
1134    impl BehaviorNode for ToggleableBehavior {
1135        async fn tick(&mut self, _ctx: &Context) -> anyhow::Result<NodeStatus> {
1136            Ok(if self.engaged.load(std::sync::atomic::Ordering::SeqCst) {
1137                NodeStatus::Running
1138            } else {
1139                NodeStatus::Failure
1140            })
1141        }
1142    }
1143
1144    /// Simulates one leg of `patrol-simple`'s timed-move sequence: `Running` for a fixed number
1145    /// of ticks, then `Success`. Records how many times it was actually ticked, so the test can
1146    /// observe whether a frozen `SequenceNode` child is skipped entirely while a higher-priority
1147    /// subsumption layer is engaged.
1148    #[derive(Debug)]
1149    struct CountedLegBehavior {
1150        running_ticks_remaining: usize,
1151        tick_count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
1152    }
1153
1154    #[async_trait]
1155    impl BehaviorNode for CountedLegBehavior {
1156        async fn tick(&mut self, _ctx: &Context) -> anyhow::Result<NodeStatus> {
1157            self.tick_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1158            if self.running_ticks_remaining == 0 {
1159                Ok(NodeStatus::Success)
1160            } else {
1161                self.running_ticks_remaining -= 1;
1162                Ok(NodeStatus::Running)
1163            }
1164        }
1165    }
1166
1167    /// The property that matters most: a `Subsumption` tree with a `Watch`-wrapped interrupt
1168    /// layer on top of a `Sequence`-based main layer must let the interrupt layer freeze the
1169    /// main layer mid-sequence (no ticks, no progress change) and then hand control back on the
1170    /// *same* tick it disengages, resuming the Sequence exactly where it was - never letting
1171    /// Success/Failure from the ceding interrupt layer leak through as the overall tick result.
1172    #[tokio::test]
1173    #[ignore = "Requires Redis infrastructure"]
1174    async fn subsumption_preserves_lower_layer_progress_across_an_interrupt() {
1175        use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1176        use std::sync::Arc;
1177
1178        let ctx = crate::test_helpers::create_test_context("subsumption_resume").await;
1179
1180        let engaged = Arc::new(AtomicBool::new(false));
1181        let leg1_ticks = Arc::new(AtomicUsize::new(0));
1182        let leg2_ticks = Arc::new(AtomicUsize::new(0));
1183
1184        let watch = WatchNode::new(
1185            vec![Box::new(ToggleableBehavior {
1186                engaged: engaged.clone(),
1187            })],
1188            ParallelPolicy::RequireOne,
1189        );
1190
1191        let sequence = SequenceNode::new(vec![
1192            Box::new(CountedLegBehavior {
1193                running_ticks_remaining: 2,
1194                tick_count: leg1_ticks.clone(),
1195            }),
1196            Box::new(CountedLegBehavior {
1197                running_ticks_remaining: 2,
1198                tick_count: leg2_ticks.clone(),
1199            }),
1200        ]);
1201
1202        let mut subsumption = SubsumptionNode::new()
1203            .add_layer(100, Box::new(watch))
1204            .add_layer(0, Box::new(sequence));
1205
1206        // Idle: the interrupt layer fails every tick, so the main layer (leg1) advances normally.
1207        assert_eq!(subsumption.tick(&ctx).await.unwrap(), NodeStatus::Running);
1208        assert_eq!(leg1_ticks.load(Ordering::SeqCst), 1);
1209        assert_eq!(subsumption.tick(&ctx).await.unwrap(), NodeStatus::Running);
1210        assert_eq!(leg1_ticks.load(Ordering::SeqCst), 2);
1211
1212        // leg1's 3rd tick resolves to Success, advancing the Sequence to leg2 within this tick.
1213        assert_eq!(subsumption.tick(&ctx).await.unwrap(), NodeStatus::Running);
1214        assert_eq!(leg1_ticks.load(Ordering::SeqCst), 3);
1215        assert_eq!(leg2_ticks.load(Ordering::SeqCst), 1);
1216
1217        // Engage the interrupt: the Sequence's current child (leg2) must never be ticked while
1218        // engaged, so its tick count must not move no matter how many cycles pass.
1219        engaged.store(true, Ordering::SeqCst);
1220        for _ in 0..3 {
1221            assert_eq!(
1222                subsumption.tick(&ctx).await.unwrap(),
1223                NodeStatus::Running,
1224                "the instance-track layer wins while engaged"
1225            );
1226        }
1227        assert_eq!(
1228            leg2_ticks.load(Ordering::SeqCst),
1229            1,
1230            "the main layer must be frozen (not reset, not advanced) while the interrupt layer is engaged"
1231        );
1232
1233        // Disengage: control falls back to the main layer within the same tick, resuming at
1234        // leg2 - not reset back to leg1 - and the overall status is the Sequence's own Running,
1235        // never a Success/Failure leaking through from the ceding interrupt layer.
1236        engaged.store(false, Ordering::SeqCst);
1237        let status = subsumption.tick(&ctx).await.unwrap();
1238        assert_eq!(
1239            status,
1240            NodeStatus::Running,
1241            "handback must surface the Sequence's own status, not Success/Failure from the ceding interrupt layer"
1242        );
1243        assert_eq!(
1244            leg1_ticks.load(Ordering::SeqCst),
1245            3,
1246            "leg1 must not be re-ticked - the sequence was not reset"
1247        );
1248        assert_eq!(
1249            leg2_ticks.load(Ordering::SeqCst),
1250            2,
1251            "the Sequence resumes ticking leg2 (not leg1) - progress was preserved, not reset"
1252        );
1253    }
1254
1255    #[derive(Debug)]
1256    struct FixedStatusBehavior(NodeStatus);
1257
1258    #[async_trait]
1259    impl BehaviorNode for FixedStatusBehavior {
1260        async fn tick(&mut self, _ctx: &Context) -> anyhow::Result<NodeStatus> {
1261            Ok(self.0)
1262        }
1263    }
1264
1265    #[tokio::test]
1266    #[ignore = "Requires Redis infrastructure"]
1267    async fn inverter_flips_success_and_failure_but_passes_running_through() {
1268        let ctx = crate::test_helpers::create_test_context("inverter").await;
1269
1270        let mut success = InverterNode::new(Box::new(FixedStatusBehavior(NodeStatus::Success)));
1271        assert_eq!(success.tick(&ctx).await.unwrap(), NodeStatus::Failure);
1272
1273        let mut failure = InverterNode::new(Box::new(FixedStatusBehavior(NodeStatus::Failure)));
1274        assert_eq!(failure.tick(&ctx).await.unwrap(), NodeStatus::Success);
1275
1276        let mut running = InverterNode::new(Box::new(FixedStatusBehavior(NodeStatus::Running)));
1277        assert_eq!(running.tick(&ctx).await.unwrap(), NodeStatus::Running);
1278    }
1279
1280    #[tokio::test]
1281    #[ignore = "Requires Redis infrastructure"]
1282    async fn force_success_reports_success_even_when_child_fails() {
1283        let ctx = crate::test_helpers::create_test_context("force_success").await;
1284
1285        let mut node = ForceSuccessNode::new(Box::new(FixedStatusBehavior(NodeStatus::Failure)));
1286        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Success);
1287
1288        let mut running = ForceSuccessNode::new(Box::new(FixedStatusBehavior(NodeStatus::Running)));
1289        assert_eq!(running.tick(&ctx).await.unwrap(), NodeStatus::Running);
1290    }
1291
1292    #[tokio::test]
1293    #[ignore = "Requires Redis infrastructure"]
1294    async fn force_failure_reports_failure_even_when_child_succeeds() {
1295        let ctx = crate::test_helpers::create_test_context("force_failure").await;
1296
1297        let mut node = ForceFailureNode::new(Box::new(FixedStatusBehavior(NodeStatus::Success)));
1298        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Failure);
1299
1300        let mut running = ForceFailureNode::new(Box::new(FixedStatusBehavior(NodeStatus::Running)));
1301        assert_eq!(running.tick(&ctx).await.unwrap(), NodeStatus::Running);
1302    }
1303
1304    #[tokio::test]
1305    #[ignore = "Requires Redis infrastructure"]
1306    async fn repeat_with_count_succeeds_after_n_successful_runs() {
1307        let ctx = crate::test_helpers::create_test_context("repeat_count").await;
1308
1309        let mut node = RepeatNode::new(Box::new(FixedStatusBehavior(NodeStatus::Success)), Some(3));
1310        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Running);
1311        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Running);
1312        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Success);
1313    }
1314
1315    #[tokio::test]
1316    #[ignore = "Requires Redis infrastructure"]
1317    async fn repeat_stops_immediately_on_child_failure() {
1318        let ctx = crate::test_helpers::create_test_context("repeat_failure").await;
1319
1320        let mut node = RepeatNode::new(Box::new(FixedStatusBehavior(NodeStatus::Failure)), None);
1321        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Failure);
1322    }
1323
1324    #[tokio::test]
1325    #[ignore = "Requires Redis infrastructure"]
1326    async fn retry_succeeds_once_an_attempt_within_the_budget_succeeds() {
1327        let ctx = crate::test_helpers::create_test_context("retry_succeeds").await;
1328
1329        let mut node = RetryNode::new(
1330            Box::new(SequencedStatusBehavior::new(vec![
1331                NodeStatus::Failure,
1332                NodeStatus::Failure,
1333                NodeStatus::Success,
1334            ])),
1335            3,
1336        );
1337        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Running);
1338        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Running);
1339        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Success);
1340    }
1341
1342    #[tokio::test]
1343    #[ignore = "Requires Redis infrastructure"]
1344    async fn retry_fails_once_every_attempt_is_exhausted() {
1345        let ctx = crate::test_helpers::create_test_context("retry_exhausted").await;
1346
1347        let mut node = RetryNode::new(Box::new(FixedStatusBehavior(NodeStatus::Failure)), 2);
1348        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Running);
1349        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Failure);
1350    }
1351
1352    #[tokio::test]
1353    #[ignore = "Requires Redis infrastructure"]
1354    async fn timeout_passes_through_a_child_that_resolves_before_the_deadline() {
1355        let ctx = crate::test_helpers::create_test_context("timeout_resolves").await;
1356
1357        let mut node = TimeoutNode::new(
1358            Box::new(FixedStatusBehavior(NodeStatus::Success)),
1359            Duration::from_secs(60),
1360        );
1361        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Success);
1362    }
1363
1364    #[tokio::test]
1365    #[ignore = "Requires Redis infrastructure"]
1366    async fn timeout_fails_once_the_child_has_been_running_too_long() {
1367        let ctx = crate::test_helpers::create_test_context("timeout_expires").await;
1368
1369        let mut node = TimeoutNode::new(Box::new(FixedStatusBehavior(NodeStatus::Running)), Duration::ZERO);
1370        // Duration::ZERO means the very first tick is already past the deadline.
1371        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Failure);
1372    }
1373}