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    // Only the exact-run finalizer sets this. Legacy HTTP Stop also sets the
89    // visible status/completed_at when it signals, which is not finalization;
90    // an unqualified finalizer cannot prove which run it finalized.
91    pub(super) execution_finalized: CancellationToken,
92    // A Host may retain an observation only after exact Router ownership or
93    // the retained FreshSync owner has been checked. It is not an authority.
94    supervisor_run_target: Option<bamboo_domain::SupervisorCurrentUserTarget>,
95    supervisor_run_workspace: Option<Option<String>>,
96
97    /// Last token budget event to replay for new subscribers.
98    ///
99    /// When a new client subscribes to an ongoing run, this
100    /// allows them to receive the most recent token usage info.
101    pub last_budget_event: Option<AgentEvent>,
102
103    /// Small ring of critical state events (TaskListUpdated, SubAgent*, etc.)
104    /// cached for replay to late/reconnecting subscribers.
105    ///
106    /// Bounded to [`CRITICAL_EVENTS_CAPACITY`] entries; oldest are evicted.
107    pub last_critical_events: Vec<AgentEvent>,
108
109    /// Name of the most recently executed tool (if any).
110    /// Updated live during execution for diagnostic visibility.
111    pub last_tool_name: Option<String>,
112
113    /// Phase of the most recently executed tool: "begin", "finished", or "error".
114    /// Updated live during execution for diagnostic visibility.
115    pub last_tool_phase: Option<String>,
116
117    /// Timestamp of the last event received during this run.
118    /// Updated live during execution for liveness checks.
119    pub last_event_at: Option<DateTime<Utc>>,
120
121    /// Number of completed rounds (turns) so far.
122    /// Updated live during execution for progress tracking.
123    pub round_count: u32,
124
125    /// Unique identifier for this execution run.
126    /// Generated fresh for every `try_reserve_runner` call so that
127    /// frontend SSE events can be matched to the correct run even
128    /// across reconnects.
129    pub run_id: String,
130}
131
132impl Default for AgentRunner {
133    fn default() -> Self {
134        Self::new()
135    }
136}
137
138impl AgentRunner {
139    /// Denial-only maintenance signal; never a claim that this run is idle.
140    pub fn root_reset_admission_reserved(&self) -> bool {
141        self.root_execution.reset_admission_reserved()
142    }
143
144    /// The Host adapter must borrow the installed runner under its registry
145    /// read/write guard. Snapshot clones cannot reserve reset admission.
146    pub(crate) fn reserve_root_reset_admission(
147        &self,
148        session: &bamboo_domain::Session,
149        epoch: u64,
150        operation_id: Uuid,
151    ) -> std::io::Result<super::root_execution_lifetime::RootResetAdmission> {
152        self.root_slot
153            .reserve_reset_admission(session, epoch, operation_id)
154    }
155
156    /// Includes lock-free event traffic as well as legacy diagnostic updates.
157    pub fn last_activity_at(&self) -> Option<DateTime<Utc>> {
158        self.last_event_at
159            .max(self.event_publication.last_event_at())
160    }
161
162    /// Host-only observation, set under the exact live router-owner fence.
163    pub fn bind_supervisor_run_target(
164        &mut self,
165        target: &bamboo_domain::SupervisorCurrentUserTarget,
166        workspace_path: Option<&str>,
167    ) {
168        self.supervisor_run_target = Some(target.clone());
169        self.supervisor_run_workspace = Some(workspace_path.map(str::to_owned));
170    }
171
172    /// This bit is set only by finalization of the matching run ID.
173    pub fn exact_execution_finalized(&self) -> bool {
174        self.execution_finalized.is_cancelled()
175    }
176
177    /// Subscribe to the exact run's finalizer without granting the caller a way to signal it.
178    pub fn wait_for_exact_finalization(
179        &self,
180    ) -> impl std::future::Future<Output = ()> + Send + 'static {
181        self.execution_finalized.clone().cancelled_owned()
182    }
183
184    /// Compare an existing Host observation without treating it as a grant.
185    /// Ordinary metadata edits may advance metadata_version, while the
186    /// canonical target and Workspace are separately revalidated by the Host.
187    pub fn supervisor_run_observation_matches(
188        &self,
189        target: &bamboo_domain::SupervisorCurrentUserTarget,
190        workspace_path: Option<&str>,
191    ) -> Option<bool> {
192        match (&self.supervisor_run_target, &self.supervisor_run_workspace) {
193            (None, None) => None,
194            (Some(observed), Some(workspace)) => {
195                let mut observed = observed.clone();
196                observed.metadata_version = target.metadata_version;
197                Some(&observed == target && workspace.as_deref() == workspace_path)
198            }
199            _ => Some(false),
200        }
201    }
202
203    /// Only the exact execution finalizer can establish this terminal fact.
204    pub fn supervisor_run_is_finalized_for(
205        &self,
206        target: &bamboo_domain::SupervisorCurrentUserTarget,
207        workspace_path: Option<&str>,
208    ) -> bool {
209        self.execution_finalized.is_cancelled()
210            && self.supervisor_run_observation_matches(target, workspace_path) == Some(true)
211    }
212    /// Broadcast channel capacity for agent events.
213    pub const EVENT_CHANNEL_CAPACITY: usize = 1000;
214
215    /// Maximum number of critical events cached for late-subscriber replay.
216    pub const CRITICAL_EVENTS_CAPACITY: usize = 32;
217
218    /// Create a new agent runner with default settings.
219    ///
220    /// Initializes a broadcast channel, a fresh cancellation token,
221    /// and `Pending` status.
222    pub fn new() -> Self {
223        let (event_sender, _) = broadcast::channel(Self::EVENT_CHANNEL_CAPACITY);
224        let root_execution = Arc::default();
225        Self {
226            root_slot: super::root_execution_lifetime::RootRunnerSlot::new(Arc::clone(
227                &root_execution,
228            )),
229            root_execution,
230            event_sender,
231            event_publication: Arc::default(),
232            visible_messages: Arc::default(),
233            cancel_token: CancellationToken::new(),
234            status: AgentStatus::Pending,
235            started_at: Utc::now(),
236            completed_at: None,
237            execution_finalized: CancellationToken::new(),
238            supervisor_run_target: None,
239            supervisor_run_workspace: None,
240            last_budget_event: None,
241            last_critical_events: Vec::new(),
242            last_tool_name: None,
243            last_tool_phase: None,
244            last_event_at: None,
245            round_count: 0,
246            run_id: Uuid::new_v4().to_string(),
247        }
248    }
249
250    /// Normal replacement transfers the unique installed-slot responsibility.
251    /// Other removals (including server idle eviction) invalidate old tokens.
252    pub(super) fn transfer_root_slot(&mut self, previous: Option<&mut Self>) {
253        if let Some(previous) = previous {
254            previous.root_slot.disarm();
255        }
256        self.root_slot.disarm();
257        self.root_slot =
258            super::root_execution_lifetime::RootRunnerSlot::new(self.root_execution.clone());
259    }
260
261    /// Push a critical state event into the bounded replay cache.
262    ///
263    /// Sub-agent lifecycle entries are snapshots keyed by stable child id, not
264    /// an event log: a rerunnable/resident child can produce many generations,
265    /// and replaying older Start/Complete pairs makes the current generation
266    /// ambiguous to reconnecting clients. Keep only the newest lifecycle state
267    /// for each child while live subscribers still receive every event.
268    ///
269    /// If the remaining cache is full, the oldest entry is evicted.
270    pub fn push_critical_event(&mut self, event: AgentEvent) {
271        let lifecycle_id = match &event {
272            AgentEvent::WorkflowActivated { event_id, .. }
273            | AgentEvent::WorkflowDeactivated { event_id, .. } => Some(event_id),
274            _ => None,
275        };
276        if lifecycle_id.is_some_and(|event_id| {
277            self.last_critical_events
278                .iter()
279                .any(|existing| match existing {
280                    AgentEvent::WorkflowActivated {
281                        event_id: existing_id,
282                        ..
283                    }
284                    | AgentEvent::WorkflowDeactivated {
285                        event_id: existing_id,
286                        ..
287                    } => existing_id == event_id,
288                    _ => false,
289                })
290        }) {
291            return;
292        }
293        if let Some(key) = subagent_lifecycle_key(&event) {
294            self.last_critical_events
295                .retain(|existing| subagent_lifecycle_key(existing) != Some(key));
296        }
297        if self.last_critical_events.len() >= Self::CRITICAL_EVENTS_CAPACITY {
298            self.last_critical_events.remove(0);
299        }
300        self.last_critical_events.push(event);
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    fn started(child_session_id: &str, title: &str) -> AgentEvent {
309        AgentEvent::SubAgentStarted {
310            parent_session_id: "parent".to_string(),
311            child_session_id: child_session_id.to_string(),
312            title: Some(title.to_string()),
313        }
314    }
315
316    fn completed(child_session_id: &str, status: &str) -> AgentEvent {
317        AgentEvent::SubAgentCompleted {
318            parent_session_id: "parent".to_string(),
319            child_session_id: child_session_id.to_string(),
320            status: status.to_string(),
321            error: None,
322        }
323    }
324
325    fn wrapped(inner: AgentEvent) -> AgentEvent {
326        AgentEvent::SubAgentEvent {
327            parent_session_id: "root".into(),
328            child_session_id: "parent".into(),
329            event: Box::new(inner),
330        }
331    }
332
333    #[test]
334    fn critical_replay_keeps_only_latest_generation_state_per_child() {
335        let mut runner = AgentRunner::new();
336
337        // A stable resident child may be reused repeatedly inside one parent
338        // runner. Reconnect must see S3 only, never the ambiguous historical
339        // sequence S1,C1,S2,C2,S3.
340        for event in [
341            started("resident", "generation-1"),
342            completed("resident", "completed"),
343            started("resident", "generation-2"),
344            completed("resident", "completed"),
345            started("resident", "generation-3"),
346        ] {
347            runner.push_critical_event(event);
348        }
349
350        assert_eq!(runner.last_critical_events.len(), 1);
351        assert!(matches!(
352            &runner.last_critical_events[0],
353            AgentEvent::SubAgentStarted {
354                child_session_id,
355                title: Some(title),
356                ..
357            } if child_session_id == "resident" && title == "generation-3"
358        ));
359
360        runner.push_critical_event(completed("resident", "completed"));
361        assert_eq!(runner.last_critical_events.len(), 1);
362        assert!(matches!(
363            &runner.last_critical_events[0],
364            AgentEvent::SubAgentCompleted {
365                child_session_id,
366                status,
367                ..
368            } if child_session_id == "resident" && status == "completed"
369        ));
370    }
371
372    #[test]
373    fn subagent_coalescing_preserves_other_children_and_recency() {
374        let mut runner = AgentRunner::new();
375        runner.push_critical_event(started("child-a", "a1"));
376        runner.push_critical_event(started("child-b", "b1"));
377        runner.push_critical_event(completed("child-a", "completed"));
378
379        assert_eq!(runner.last_critical_events.len(), 2);
380        assert!(matches!(
381            &runner.last_critical_events[0],
382            AgentEvent::SubAgentStarted {
383                child_session_id, ..
384            } if child_session_id == "child-b"
385        ));
386        assert!(matches!(
387            &runner.last_critical_events[1],
388            AgentEvent::SubAgentCompleted {
389                child_session_id, ..
390            } if child_session_id == "child-a"
391        ));
392    }
393
394    #[test]
395    fn wrapped_legacy_lifecycle_replays_once_and_cannot_double_count_direct_lifecycle() {
396        let mut runner = AgentRunner::new();
397        let legacy_start = wrapped(started("child-a", "initial"));
398        assert!(legacy_start.is_replayable_session_state());
399        runner.push_critical_event(legacy_start);
400        runner.push_critical_event(started("child-a", "direct"));
401        assert_eq!(runner.last_critical_events.len(), 1);
402        assert!(matches!(
403            &runner.last_critical_events[0],
404            AgentEvent::SubAgentStarted { title: Some(title), .. } if title == "direct"
405        ));
406        runner.push_critical_event(wrapped(completed("child-a", "completed")));
407        assert_eq!(runner.last_critical_events.len(), 1);
408        assert!(matches!(
409            &runner.last_critical_events[0],
410            AgentEvent::SubAgentEvent { .. }
411        ));
412        assert!(runner.last_critical_events.len() <= AgentRunner::CRITICAL_EVENTS_CAPACITY);
413
414        let invalid = AgentEvent::SubAgentEvent {
415            parent_session_id: "root".into(),
416            child_session_id: "unrelated".into(),
417            event: Box::new(started("child-b", "forged")),
418        };
419        assert!(!invalid.is_replayable_session_state());
420        assert!(invalid.legacy_child_lifecycle_observation().is_none());
421    }
422}