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 /// Broadcast sender for agent events.
65 ///
66 /// Allows multiple clients to subscribe to agent events
67 /// via `event_sender.subscribe()`.
68 pub event_sender: broadcast::Sender<AgentEvent>,
69
70 /// Cancellation token for graceful shutdown.
71 ///
72 /// When triggered, the agent should stop execution at the
73 /// next safe point.
74 pub cancel_token: CancellationToken,
75
76 /// Current status of the agent run.
77 pub status: AgentStatus,
78
79 /// Timestamp when the run was started.
80 pub started_at: DateTime<Utc>,
81
82 /// Timestamp when the run completed (if finished).
83 pub completed_at: Option<DateTime<Utc>>,
84
85 /// Last token budget event to replay for new subscribers.
86 ///
87 /// When a new client subscribes to an ongoing run, this
88 /// allows them to receive the most recent token usage info.
89 pub last_budget_event: Option<AgentEvent>,
90
91 /// Small ring of critical state events (TaskListUpdated, SubAgent*, etc.)
92 /// cached for replay to late/reconnecting subscribers.
93 ///
94 /// Bounded to [`CRITICAL_EVENTS_CAPACITY`] entries; oldest are evicted.
95 pub last_critical_events: Vec<AgentEvent>,
96
97 /// Name of the most recently executed tool (if any).
98 /// Updated live during execution for diagnostic visibility.
99 pub last_tool_name: Option<String>,
100
101 /// Phase of the most recently executed tool: "begin", "finished", or "error".
102 /// Updated live during execution for diagnostic visibility.
103 pub last_tool_phase: Option<String>,
104
105 /// Timestamp of the last event received during this run.
106 /// Updated live during execution for liveness checks.
107 pub last_event_at: Option<DateTime<Utc>>,
108
109 /// Number of completed rounds (turns) so far.
110 /// Updated live during execution for progress tracking.
111 pub round_count: u32,
112
113 /// Unique identifier for this execution run.
114 /// Generated fresh for every `try_reserve_runner` call so that
115 /// frontend SSE events can be matched to the correct run even
116 /// across reconnects.
117 pub run_id: String,
118}
119
120impl Default for AgentRunner {
121 fn default() -> Self {
122 Self::new()
123 }
124}
125
126impl AgentRunner {
127 /// Broadcast channel capacity for agent events.
128 pub const EVENT_CHANNEL_CAPACITY: usize = 1000;
129
130 /// Maximum number of critical events cached for late-subscriber replay.
131 pub const CRITICAL_EVENTS_CAPACITY: usize = 32;
132
133 /// Create a new agent runner with default settings.
134 ///
135 /// Initializes a broadcast channel, a fresh cancellation token,
136 /// and `Pending` status.
137 pub fn new() -> Self {
138 let (event_sender, _) = broadcast::channel(Self::EVENT_CHANNEL_CAPACITY);
139 Self {
140 event_sender,
141 cancel_token: CancellationToken::new(),
142 status: AgentStatus::Pending,
143 started_at: Utc::now(),
144 completed_at: None,
145 last_budget_event: None,
146 last_critical_events: Vec::new(),
147 last_tool_name: None,
148 last_tool_phase: None,
149 last_event_at: None,
150 round_count: 0,
151 run_id: Uuid::new_v4().to_string(),
152 }
153 }
154
155 /// Push a critical state event into the bounded replay cache.
156 ///
157 /// Sub-agent lifecycle entries are snapshots keyed by stable child id, not
158 /// an event log: a rerunnable/resident child can produce many generations,
159 /// and replaying older Start/Complete pairs makes the current generation
160 /// ambiguous to reconnecting clients. Keep only the newest lifecycle state
161 /// for each child while live subscribers still receive every event.
162 ///
163 /// If the remaining cache is full, the oldest entry is evicted.
164 pub fn push_critical_event(&mut self, event: AgentEvent) {
165 let lifecycle_id = match &event {
166 AgentEvent::WorkflowActivated { event_id, .. }
167 | AgentEvent::WorkflowDeactivated { event_id, .. } => Some(event_id),
168 _ => None,
169 };
170 if lifecycle_id.is_some_and(|event_id| {
171 self.last_critical_events
172 .iter()
173 .any(|existing| match existing {
174 AgentEvent::WorkflowActivated {
175 event_id: existing_id,
176 ..
177 }
178 | AgentEvent::WorkflowDeactivated {
179 event_id: existing_id,
180 ..
181 } => existing_id == event_id,
182 _ => false,
183 })
184 }) {
185 return;
186 }
187 if let Some(child_session_id) = subagent_lifecycle_child_id(&event) {
188 self.last_critical_events
189 .retain(|existing| subagent_lifecycle_child_id(existing) != Some(child_session_id));
190 }
191 if self.last_critical_events.len() >= Self::CRITICAL_EVENTS_CAPACITY {
192 self.last_critical_events.remove(0);
193 }
194 self.last_critical_events.push(event);
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201
202 fn started(child_session_id: &str, title: &str) -> AgentEvent {
203 AgentEvent::SubAgentStarted {
204 parent_session_id: "parent".to_string(),
205 child_session_id: child_session_id.to_string(),
206 title: Some(title.to_string()),
207 }
208 }
209
210 fn completed(child_session_id: &str, status: &str) -> AgentEvent {
211 AgentEvent::SubAgentCompleted {
212 parent_session_id: "parent".to_string(),
213 child_session_id: child_session_id.to_string(),
214 status: status.to_string(),
215 error: None,
216 }
217 }
218
219 #[test]
220 fn critical_replay_keeps_only_latest_generation_state_per_child() {
221 let mut runner = AgentRunner::new();
222
223 // A stable resident child may be reused repeatedly inside one parent
224 // runner. Reconnect must see S3 only, never the ambiguous historical
225 // sequence S1,C1,S2,C2,S3.
226 for event in [
227 started("resident", "generation-1"),
228 completed("resident", "completed"),
229 started("resident", "generation-2"),
230 completed("resident", "completed"),
231 started("resident", "generation-3"),
232 ] {
233 runner.push_critical_event(event);
234 }
235
236 assert_eq!(runner.last_critical_events.len(), 1);
237 assert!(matches!(
238 &runner.last_critical_events[0],
239 AgentEvent::SubAgentStarted {
240 child_session_id,
241 title: Some(title),
242 ..
243 } if child_session_id == "resident" && title == "generation-3"
244 ));
245
246 runner.push_critical_event(completed("resident", "completed"));
247 assert_eq!(runner.last_critical_events.len(), 1);
248 assert!(matches!(
249 &runner.last_critical_events[0],
250 AgentEvent::SubAgentCompleted {
251 child_session_id,
252 status,
253 ..
254 } if child_session_id == "resident" && status == "completed"
255 ));
256 }
257
258 #[test]
259 fn subagent_coalescing_preserves_other_children_and_recency() {
260 let mut runner = AgentRunner::new();
261 runner.push_critical_event(started("child-a", "a1"));
262 runner.push_critical_event(started("child-b", "b1"));
263 runner.push_critical_event(completed("child-a", "completed"));
264
265 assert_eq!(runner.last_critical_events.len(), 2);
266 assert!(matches!(
267 &runner.last_critical_events[0],
268 AgentEvent::SubAgentStarted {
269 child_session_id, ..
270 } if child_session_id == "child-b"
271 ));
272 assert!(matches!(
273 &runner.last_critical_events[1],
274 AgentEvent::SubAgentCompleted {
275 child_session_id, ..
276 } if child_session_id == "child-a"
277 ));
278 }
279}