Skip to main content

bamboo_engine/runtime/execution/
runner_state.rs

1//! Runner state types for background agent execution.
2//!
3//! Provides the `AgentRunner` and `AgentStatus` types that track the lifecycle
4//! of an in-progress agent execution. These are used by the execution
5//! orchestration layer across all background paths (HTTP execute, spawn, schedule).
6
7use chrono::{DateTime, Utc};
8use tokio::sync::broadcast;
9use tokio_util::sync::CancellationToken;
10use uuid::Uuid;
11
12use bamboo_agent_core::AgentEvent;
13
14fn subagent_lifecycle_child_id(event: &AgentEvent) -> Option<&str> {
15    match event {
16        AgentEvent::SubAgentStarted {
17            child_session_id, ..
18        }
19        | AgentEvent::SubAgentCompleted {
20            child_session_id, ..
21        } => Some(child_session_id),
22        _ => None,
23    }
24}
25
26/// Status of an agent execution runner.
27///
28/// Represents the lifecycle state of an agent run from initialization
29/// through completion or error.
30#[derive(Debug, Clone)]
31pub enum AgentStatus {
32    /// Agent is initialized but not yet running.
33    Pending,
34
35    /// Agent is currently executing.
36    Running,
37
38    /// Agent completed successfully.
39    Completed,
40
41    /// Agent execution was cancelled by user.
42    Cancelled,
43
44    /// Agent execution failed with an error message.
45    Error(String),
46}
47
48/// Runner that manages agent execution for a session.
49///
50/// Each active agent run has an associated `AgentRunner` that coordinates
51/// event broadcasting, cancellation, and status tracking.
52///
53/// # Event Broadcasting
54///
55/// Uses a broadcast channel to support multiple subscribers watching
56/// the same agent run simultaneously.
57///
58/// # Cancellation
59///
60/// Provides a cancellation token that can be used to gracefully stop
61/// an in-progress agent execution.
62#[derive(Debug, Clone)]
63pub struct AgentRunner {
64    /// Generation fence and activity clock shared only by this run's producers.
65    pub event_publication: std::sync::Arc<super::event_publication::EventPublication>,
66    /// Broadcast sender for agent events.
67    ///
68    /// Allows multiple clients to subscribe to agent events
69    /// via `event_sender.subscribe()`.
70    pub event_sender: broadcast::Sender<AgentEvent>,
71
72    /// Cancellation token for graceful shutdown.
73    ///
74    /// When triggered, the agent should stop execution at the
75    /// next safe point.
76    pub cancel_token: CancellationToken,
77
78    /// Current status of the agent run.
79    pub status: AgentStatus,
80
81    /// Timestamp when the run was started.
82    pub started_at: DateTime<Utc>,
83
84    /// Timestamp when the run completed (if finished).
85    pub completed_at: Option<DateTime<Utc>>,
86
87    /// Last token budget event to replay for new subscribers.
88    ///
89    /// When a new client subscribes to an ongoing run, this
90    /// allows them to receive the most recent token usage info.
91    pub last_budget_event: Option<AgentEvent>,
92
93    /// Small ring of critical state events (TaskListUpdated, SubAgent*, etc.)
94    /// cached for replay to late/reconnecting subscribers.
95    ///
96    /// Bounded to [`CRITICAL_EVENTS_CAPACITY`] entries; oldest are evicted.
97    pub last_critical_events: Vec<AgentEvent>,
98
99    /// Name of the most recently executed tool (if any).
100    /// Updated live during execution for diagnostic visibility.
101    pub last_tool_name: Option<String>,
102
103    /// Phase of the most recently executed tool: "begin", "finished", or "error".
104    /// Updated live during execution for diagnostic visibility.
105    pub last_tool_phase: Option<String>,
106
107    /// Timestamp of the last event received during this run.
108    /// Updated live during execution for liveness checks.
109    pub last_event_at: Option<DateTime<Utc>>,
110
111    /// Number of completed rounds (turns) so far.
112    /// Updated live during execution for progress tracking.
113    pub round_count: u32,
114
115    /// Unique identifier for this execution run.
116    /// Generated fresh for every `try_reserve_runner` call so that
117    /// frontend SSE events can be matched to the correct run even
118    /// across reconnects.
119    pub run_id: String,
120}
121
122impl Default for AgentRunner {
123    fn default() -> Self {
124        Self::new()
125    }
126}
127
128impl AgentRunner {
129    /// Includes lock-free event traffic as well as legacy diagnostic updates.
130    pub fn last_activity_at(&self) -> Option<DateTime<Utc>> {
131        self.last_event_at
132            .max(self.event_publication.last_event_at())
133    }
134    /// Broadcast channel capacity for agent events.
135    pub const EVENT_CHANNEL_CAPACITY: usize = 1000;
136
137    /// Maximum number of critical events cached for late-subscriber replay.
138    pub const CRITICAL_EVENTS_CAPACITY: usize = 32;
139
140    /// Create a new agent runner with default settings.
141    ///
142    /// Initializes a broadcast channel, a fresh cancellation token,
143    /// and `Pending` status.
144    pub fn new() -> Self {
145        let (event_sender, _) = broadcast::channel(Self::EVENT_CHANNEL_CAPACITY);
146        Self {
147            event_sender,
148            event_publication: std::sync::Arc::default(),
149            cancel_token: CancellationToken::new(),
150            status: AgentStatus::Pending,
151            started_at: Utc::now(),
152            completed_at: None,
153            last_budget_event: None,
154            last_critical_events: Vec::new(),
155            last_tool_name: None,
156            last_tool_phase: None,
157            last_event_at: None,
158            round_count: 0,
159            run_id: Uuid::new_v4().to_string(),
160        }
161    }
162
163    /// Push a critical state event into the bounded replay cache.
164    ///
165    /// Sub-agent lifecycle entries are snapshots keyed by stable child id, not
166    /// an event log: a rerunnable/resident child can produce many generations,
167    /// and replaying older Start/Complete pairs makes the current generation
168    /// ambiguous to reconnecting clients. Keep only the newest lifecycle state
169    /// for each child while live subscribers still receive every event.
170    ///
171    /// If the remaining cache is full, the oldest entry is evicted.
172    pub fn push_critical_event(&mut self, event: AgentEvent) {
173        let lifecycle_id = match &event {
174            AgentEvent::WorkflowActivated { event_id, .. }
175            | AgentEvent::WorkflowDeactivated { event_id, .. } => Some(event_id),
176            _ => None,
177        };
178        if lifecycle_id.is_some_and(|event_id| {
179            self.last_critical_events
180                .iter()
181                .any(|existing| match existing {
182                    AgentEvent::WorkflowActivated {
183                        event_id: existing_id,
184                        ..
185                    }
186                    | AgentEvent::WorkflowDeactivated {
187                        event_id: existing_id,
188                        ..
189                    } => existing_id == event_id,
190                    _ => false,
191                })
192        }) {
193            return;
194        }
195        if let Some(child_session_id) = subagent_lifecycle_child_id(&event) {
196            self.last_critical_events
197                .retain(|existing| subagent_lifecycle_child_id(existing) != Some(child_session_id));
198        }
199        if self.last_critical_events.len() >= Self::CRITICAL_EVENTS_CAPACITY {
200            self.last_critical_events.remove(0);
201        }
202        self.last_critical_events.push(event);
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    fn started(child_session_id: &str, title: &str) -> AgentEvent {
211        AgentEvent::SubAgentStarted {
212            parent_session_id: "parent".to_string(),
213            child_session_id: child_session_id.to_string(),
214            title: Some(title.to_string()),
215        }
216    }
217
218    fn completed(child_session_id: &str, status: &str) -> AgentEvent {
219        AgentEvent::SubAgentCompleted {
220            parent_session_id: "parent".to_string(),
221            child_session_id: child_session_id.to_string(),
222            status: status.to_string(),
223            error: None,
224        }
225    }
226
227    #[test]
228    fn critical_replay_keeps_only_latest_generation_state_per_child() {
229        let mut runner = AgentRunner::new();
230
231        // A stable resident child may be reused repeatedly inside one parent
232        // runner. Reconnect must see S3 only, never the ambiguous historical
233        // sequence S1,C1,S2,C2,S3.
234        for event in [
235            started("resident", "generation-1"),
236            completed("resident", "completed"),
237            started("resident", "generation-2"),
238            completed("resident", "completed"),
239            started("resident", "generation-3"),
240        ] {
241            runner.push_critical_event(event);
242        }
243
244        assert_eq!(runner.last_critical_events.len(), 1);
245        assert!(matches!(
246            &runner.last_critical_events[0],
247            AgentEvent::SubAgentStarted {
248                child_session_id,
249                title: Some(title),
250                ..
251            } if child_session_id == "resident" && title == "generation-3"
252        ));
253
254        runner.push_critical_event(completed("resident", "completed"));
255        assert_eq!(runner.last_critical_events.len(), 1);
256        assert!(matches!(
257            &runner.last_critical_events[0],
258            AgentEvent::SubAgentCompleted {
259                child_session_id,
260                status,
261                ..
262            } if child_session_id == "resident" && status == "completed"
263        ));
264    }
265
266    #[test]
267    fn subagent_coalescing_preserves_other_children_and_recency() {
268        let mut runner = AgentRunner::new();
269        runner.push_critical_event(started("child-a", "a1"));
270        runner.push_critical_event(started("child-b", "b1"));
271        runner.push_critical_event(completed("child-a", "completed"));
272
273        assert_eq!(runner.last_critical_events.len(), 2);
274        assert!(matches!(
275            &runner.last_critical_events[0],
276            AgentEvent::SubAgentStarted {
277                child_session_id, ..
278            } if child_session_id == "child-b"
279        ));
280        assert!(matches!(
281            &runner.last_critical_events[1],
282            AgentEvent::SubAgentCompleted {
283                child_session_id, ..
284            } if child_session_id == "child-a"
285        ));
286    }
287}