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 std::sync::Arc;
9use tokio::sync::broadcast;
10use tokio_util::sync::CancellationToken;
11use uuid::Uuid;
12
13use bamboo_agent_core::AgentEvent;
14
15fn subagent_lifecycle_key(event: &AgentEvent) -> Option<(&str, &str)> {
16    event
17        .legacy_child_lifecycle_observation()
18        .map(|observation| (observation.parent_session_id, observation.child_session_id))
19}
20
21/// Status of an agent execution runner.
22///
23/// Represents the lifecycle state of an agent run from initialization
24/// through completion or error.
25#[derive(Debug, Clone)]
26pub enum AgentStatus {
27    /// Agent is initialized but not yet running.
28    Pending,
29
30    /// Agent is currently executing.
31    Running,
32
33    /// Agent completed successfully.
34    Completed,
35
36    /// Agent execution was cancelled by user.
37    Cancelled,
38
39    /// Agent execution failed with an error message.
40    Error(String),
41}
42
43/// Runner that manages agent execution for a session.
44///
45/// Each active agent run has an associated `AgentRunner` that coordinates
46/// event broadcasting, cancellation, and status tracking.
47///
48/// # Event Broadcasting
49///
50/// Uses a broadcast channel to support multiple subscribers watching
51/// the same agent run simultaneously.
52///
53/// # Cancellation
54///
55/// Provides a cancellation token that can be used to gracefully stop
56/// an in-progress agent execution.
57#[derive(Debug, Clone)]
58pub struct AgentRunner {
59    /// Physical outer-task lineage. Visible terminal status cannot replace it.
60    pub(crate) root_execution: Arc<super::root_execution_lifetime::RootExecutionLifetime>,
61    root_slot: super::root_execution_lifetime::RootRunnerSlot,
62    /// Generation fence and activity clock shared only by this run's producers.
63    pub event_publication: Arc<super::event_publication::EventPublication>,
64
65    /// Message-only visible-text replay and broadcast for this exact run.
66    pub visible_messages: Arc<super::visible_messages::VisibleMessageStream>,
67    /// Broadcast sender for agent events.
68    ///
69    /// Allows multiple clients to subscribe to agent events
70    /// via `event_sender.subscribe()`.
71    pub event_sender: broadcast::Sender<AgentEvent>,
72
73    /// Cancellation token for graceful shutdown.
74    ///
75    /// When triggered, the agent should stop execution at the
76    /// next safe point.
77    pub cancel_token: CancellationToken,
78
79    /// Current status of the agent run.
80    pub status: AgentStatus,
81
82    /// Timestamp when the run was started.
83    pub started_at: DateTime<Utc>,
84
85    /// Timestamp when the run completed (if finished).
86    pub completed_at: Option<DateTime<Utc>>,
87
88    /// Last token budget event to replay for new subscribers.
89    ///
90    /// When a new client subscribes to an ongoing run, this
91    /// allows them to receive the most recent token usage info.
92    pub last_budget_event: Option<AgentEvent>,
93
94    /// Small ring of critical state events (TaskListUpdated, SubAgent*, etc.)
95    /// cached for replay to late/reconnecting subscribers.
96    ///
97    /// Bounded to [`CRITICAL_EVENTS_CAPACITY`] entries; oldest are evicted.
98    pub last_critical_events: Vec<AgentEvent>,
99
100    /// Name of the most recently executed tool (if any).
101    /// Updated live during execution for diagnostic visibility.
102    pub last_tool_name: Option<String>,
103
104    /// Phase of the most recently executed tool: "begin", "finished", or "error".
105    /// Updated live during execution for diagnostic visibility.
106    pub last_tool_phase: Option<String>,
107
108    /// Timestamp of the last event received during this run.
109    /// Updated live during execution for liveness checks.
110    pub last_event_at: Option<DateTime<Utc>>,
111
112    /// Number of completed rounds (turns) so far.
113    /// Updated live during execution for progress tracking.
114    pub round_count: u32,
115
116    /// Unique identifier for this execution run.
117    /// Generated fresh for every `try_reserve_runner` call so that
118    /// frontend SSE events can be matched to the correct run even
119    /// across reconnects.
120    pub run_id: String,
121}
122
123impl Default for AgentRunner {
124    fn default() -> Self {
125        Self::new()
126    }
127}
128
129impl AgentRunner {
130    /// Denial-only maintenance signal; never a claim that this run is idle.
131    pub fn root_reset_admission_reserved(&self) -> bool {
132        self.root_execution.reset_admission_reserved()
133    }
134
135    /// The Host adapter must borrow the installed runner under its registry
136    /// read/write guard. Snapshot clones cannot reserve reset admission.
137    pub(crate) fn reserve_root_reset_admission(
138        &self,
139        session: &bamboo_domain::Session,
140        epoch: u64,
141        operation_id: Uuid,
142    ) -> std::io::Result<super::root_execution_lifetime::RootResetAdmission> {
143        self.root_slot
144            .reserve_reset_admission(session, epoch, operation_id)
145    }
146
147    /// Includes lock-free event traffic as well as legacy diagnostic updates.
148    pub fn last_activity_at(&self) -> Option<DateTime<Utc>> {
149        self.last_event_at
150            .max(self.event_publication.last_event_at())
151    }
152    /// Broadcast channel capacity for agent events.
153    pub const EVENT_CHANNEL_CAPACITY: usize = 1000;
154
155    /// Maximum number of critical events cached for late-subscriber replay.
156    pub const CRITICAL_EVENTS_CAPACITY: usize = 32;
157
158    /// Create a new agent runner with default settings.
159    ///
160    /// Initializes a broadcast channel, a fresh cancellation token,
161    /// and `Pending` status.
162    pub fn new() -> Self {
163        let (event_sender, _) = broadcast::channel(Self::EVENT_CHANNEL_CAPACITY);
164        let root_execution = Arc::default();
165        Self {
166            root_slot: super::root_execution_lifetime::RootRunnerSlot::new(Arc::clone(
167                &root_execution,
168            )),
169            root_execution,
170            event_sender,
171            event_publication: Arc::default(),
172            visible_messages: Arc::default(),
173            cancel_token: CancellationToken::new(),
174            status: AgentStatus::Pending,
175            started_at: Utc::now(),
176            completed_at: None,
177            last_budget_event: None,
178            last_critical_events: Vec::new(),
179            last_tool_name: None,
180            last_tool_phase: None,
181            last_event_at: None,
182            round_count: 0,
183            run_id: Uuid::new_v4().to_string(),
184        }
185    }
186
187    /// Normal replacement transfers the unique installed-slot responsibility.
188    /// Other removals (including server idle eviction) invalidate old tokens.
189    pub(super) fn transfer_root_slot(&mut self, previous: Option<&mut Self>) {
190        if let Some(previous) = previous {
191            previous.root_slot.disarm();
192        }
193        self.root_slot.disarm();
194        self.root_slot =
195            super::root_execution_lifetime::RootRunnerSlot::new(self.root_execution.clone());
196    }
197
198    /// Push a critical state event into the bounded replay cache.
199    ///
200    /// Sub-agent lifecycle entries are snapshots keyed by stable child id, not
201    /// an event log: a rerunnable/resident child can produce many generations,
202    /// and replaying older Start/Complete pairs makes the current generation
203    /// ambiguous to reconnecting clients. Keep only the newest lifecycle state
204    /// for each child while live subscribers still receive every event.
205    ///
206    /// If the remaining cache is full, the oldest entry is evicted.
207    pub fn push_critical_event(&mut self, event: AgentEvent) {
208        let lifecycle_id = match &event {
209            AgentEvent::WorkflowActivated { event_id, .. }
210            | AgentEvent::WorkflowDeactivated { event_id, .. } => Some(event_id),
211            _ => None,
212        };
213        if lifecycle_id.is_some_and(|event_id| {
214            self.last_critical_events
215                .iter()
216                .any(|existing| match existing {
217                    AgentEvent::WorkflowActivated {
218                        event_id: existing_id,
219                        ..
220                    }
221                    | AgentEvent::WorkflowDeactivated {
222                        event_id: existing_id,
223                        ..
224                    } => existing_id == event_id,
225                    _ => false,
226                })
227        }) {
228            return;
229        }
230        if let Some(key) = subagent_lifecycle_key(&event) {
231            self.last_critical_events
232                .retain(|existing| subagent_lifecycle_key(existing) != Some(key));
233        }
234        if self.last_critical_events.len() >= Self::CRITICAL_EVENTS_CAPACITY {
235            self.last_critical_events.remove(0);
236        }
237        self.last_critical_events.push(event);
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    fn started(child_session_id: &str, title: &str) -> AgentEvent {
246        AgentEvent::SubAgentStarted {
247            parent_session_id: "parent".to_string(),
248            child_session_id: child_session_id.to_string(),
249            title: Some(title.to_string()),
250        }
251    }
252
253    fn completed(child_session_id: &str, status: &str) -> AgentEvent {
254        AgentEvent::SubAgentCompleted {
255            parent_session_id: "parent".to_string(),
256            child_session_id: child_session_id.to_string(),
257            status: status.to_string(),
258            error: None,
259        }
260    }
261
262    fn wrapped(inner: AgentEvent) -> AgentEvent {
263        AgentEvent::SubAgentEvent {
264            parent_session_id: "root".into(),
265            child_session_id: "parent".into(),
266            event: Box::new(inner),
267        }
268    }
269
270    #[test]
271    fn critical_replay_keeps_only_latest_generation_state_per_child() {
272        let mut runner = AgentRunner::new();
273
274        // A stable resident child may be reused repeatedly inside one parent
275        // runner. Reconnect must see S3 only, never the ambiguous historical
276        // sequence S1,C1,S2,C2,S3.
277        for event in [
278            started("resident", "generation-1"),
279            completed("resident", "completed"),
280            started("resident", "generation-2"),
281            completed("resident", "completed"),
282            started("resident", "generation-3"),
283        ] {
284            runner.push_critical_event(event);
285        }
286
287        assert_eq!(runner.last_critical_events.len(), 1);
288        assert!(matches!(
289            &runner.last_critical_events[0],
290            AgentEvent::SubAgentStarted {
291                child_session_id,
292                title: Some(title),
293                ..
294            } if child_session_id == "resident" && title == "generation-3"
295        ));
296
297        runner.push_critical_event(completed("resident", "completed"));
298        assert_eq!(runner.last_critical_events.len(), 1);
299        assert!(matches!(
300            &runner.last_critical_events[0],
301            AgentEvent::SubAgentCompleted {
302                child_session_id,
303                status,
304                ..
305            } if child_session_id == "resident" && status == "completed"
306        ));
307    }
308
309    #[test]
310    fn subagent_coalescing_preserves_other_children_and_recency() {
311        let mut runner = AgentRunner::new();
312        runner.push_critical_event(started("child-a", "a1"));
313        runner.push_critical_event(started("child-b", "b1"));
314        runner.push_critical_event(completed("child-a", "completed"));
315
316        assert_eq!(runner.last_critical_events.len(), 2);
317        assert!(matches!(
318            &runner.last_critical_events[0],
319            AgentEvent::SubAgentStarted {
320                child_session_id, ..
321            } if child_session_id == "child-b"
322        ));
323        assert!(matches!(
324            &runner.last_critical_events[1],
325            AgentEvent::SubAgentCompleted {
326                child_session_id, ..
327            } if child_session_id == "child-a"
328        ));
329    }
330
331    #[test]
332    fn wrapped_legacy_lifecycle_replays_once_and_cannot_double_count_direct_lifecycle() {
333        let mut runner = AgentRunner::new();
334        let legacy_start = wrapped(started("child-a", "initial"));
335        assert!(legacy_start.is_replayable_session_state());
336        runner.push_critical_event(legacy_start);
337        runner.push_critical_event(started("child-a", "direct"));
338        assert_eq!(runner.last_critical_events.len(), 1);
339        assert!(matches!(
340            &runner.last_critical_events[0],
341            AgentEvent::SubAgentStarted { title: Some(title), .. } if title == "direct"
342        ));
343        runner.push_critical_event(wrapped(completed("child-a", "completed")));
344        assert_eq!(runner.last_critical_events.len(), 1);
345        assert!(matches!(
346            &runner.last_critical_events[0],
347            AgentEvent::SubAgentEvent { .. }
348        ));
349        assert!(runner.last_critical_events.len() <= AgentRunner::CRITICAL_EVENTS_CAPACITY);
350
351        let invalid = AgentEvent::SubAgentEvent {
352            parent_session_id: "root".into(),
353            child_session_id: "unrelated".into(),
354            event: Box::new(started("child-b", "forged")),
355        };
356        assert!(!invalid.is_replayable_session_state());
357        assert!(invalid.legacy_child_lifecycle_observation().is_none());
358    }
359}