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: bool,
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
175    }
176
177    /// Compare an existing Host observation without treating it as a grant.
178    /// Ordinary metadata edits may advance metadata_version, while the
179    /// canonical target and Workspace are separately revalidated by the Host.
180    pub fn supervisor_run_observation_matches(
181        &self,
182        target: &bamboo_domain::SupervisorCurrentUserTarget,
183        workspace_path: Option<&str>,
184    ) -> Option<bool> {
185        match (&self.supervisor_run_target, &self.supervisor_run_workspace) {
186            (None, None) => None,
187            (Some(observed), Some(workspace)) => {
188                let mut observed = observed.clone();
189                observed.metadata_version = target.metadata_version;
190                Some(&observed == target && workspace.as_deref() == workspace_path)
191            }
192            _ => Some(false),
193        }
194    }
195
196    /// Only the exact execution finalizer can establish this terminal fact.
197    pub fn supervisor_run_is_finalized_for(
198        &self,
199        target: &bamboo_domain::SupervisorCurrentUserTarget,
200        workspace_path: Option<&str>,
201    ) -> bool {
202        self.execution_finalized
203            && self.supervisor_run_observation_matches(target, workspace_path) == Some(true)
204    }
205    /// Broadcast channel capacity for agent events.
206    pub const EVENT_CHANNEL_CAPACITY: usize = 1000;
207
208    /// Maximum number of critical events cached for late-subscriber replay.
209    pub const CRITICAL_EVENTS_CAPACITY: usize = 32;
210
211    /// Create a new agent runner with default settings.
212    ///
213    /// Initializes a broadcast channel, a fresh cancellation token,
214    /// and `Pending` status.
215    pub fn new() -> Self {
216        let (event_sender, _) = broadcast::channel(Self::EVENT_CHANNEL_CAPACITY);
217        let root_execution = Arc::default();
218        Self {
219            root_slot: super::root_execution_lifetime::RootRunnerSlot::new(Arc::clone(
220                &root_execution,
221            )),
222            root_execution,
223            event_sender,
224            event_publication: Arc::default(),
225            visible_messages: Arc::default(),
226            cancel_token: CancellationToken::new(),
227            status: AgentStatus::Pending,
228            started_at: Utc::now(),
229            completed_at: None,
230            execution_finalized: false,
231            supervisor_run_target: None,
232            supervisor_run_workspace: None,
233            last_budget_event: None,
234            last_critical_events: Vec::new(),
235            last_tool_name: None,
236            last_tool_phase: None,
237            last_event_at: None,
238            round_count: 0,
239            run_id: Uuid::new_v4().to_string(),
240        }
241    }
242
243    /// Normal replacement transfers the unique installed-slot responsibility.
244    /// Other removals (including server idle eviction) invalidate old tokens.
245    pub(super) fn transfer_root_slot(&mut self, previous: Option<&mut Self>) {
246        if let Some(previous) = previous {
247            previous.root_slot.disarm();
248        }
249        self.root_slot.disarm();
250        self.root_slot =
251            super::root_execution_lifetime::RootRunnerSlot::new(self.root_execution.clone());
252    }
253
254    /// Push a critical state event into the bounded replay cache.
255    ///
256    /// Sub-agent lifecycle entries are snapshots keyed by stable child id, not
257    /// an event log: a rerunnable/resident child can produce many generations,
258    /// and replaying older Start/Complete pairs makes the current generation
259    /// ambiguous to reconnecting clients. Keep only the newest lifecycle state
260    /// for each child while live subscribers still receive every event.
261    ///
262    /// If the remaining cache is full, the oldest entry is evicted.
263    pub fn push_critical_event(&mut self, event: AgentEvent) {
264        let lifecycle_id = match &event {
265            AgentEvent::WorkflowActivated { event_id, .. }
266            | AgentEvent::WorkflowDeactivated { event_id, .. } => Some(event_id),
267            _ => None,
268        };
269        if lifecycle_id.is_some_and(|event_id| {
270            self.last_critical_events
271                .iter()
272                .any(|existing| match existing {
273                    AgentEvent::WorkflowActivated {
274                        event_id: existing_id,
275                        ..
276                    }
277                    | AgentEvent::WorkflowDeactivated {
278                        event_id: existing_id,
279                        ..
280                    } => existing_id == event_id,
281                    _ => false,
282                })
283        }) {
284            return;
285        }
286        if let Some(key) = subagent_lifecycle_key(&event) {
287            self.last_critical_events
288                .retain(|existing| subagent_lifecycle_key(existing) != Some(key));
289        }
290        if self.last_critical_events.len() >= Self::CRITICAL_EVENTS_CAPACITY {
291            self.last_critical_events.remove(0);
292        }
293        self.last_critical_events.push(event);
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    fn started(child_session_id: &str, title: &str) -> AgentEvent {
302        AgentEvent::SubAgentStarted {
303            parent_session_id: "parent".to_string(),
304            child_session_id: child_session_id.to_string(),
305            title: Some(title.to_string()),
306        }
307    }
308
309    fn completed(child_session_id: &str, status: &str) -> AgentEvent {
310        AgentEvent::SubAgentCompleted {
311            parent_session_id: "parent".to_string(),
312            child_session_id: child_session_id.to_string(),
313            status: status.to_string(),
314            error: None,
315        }
316    }
317
318    fn wrapped(inner: AgentEvent) -> AgentEvent {
319        AgentEvent::SubAgentEvent {
320            parent_session_id: "root".into(),
321            child_session_id: "parent".into(),
322            event: Box::new(inner),
323        }
324    }
325
326    #[test]
327    fn critical_replay_keeps_only_latest_generation_state_per_child() {
328        let mut runner = AgentRunner::new();
329
330        // A stable resident child may be reused repeatedly inside one parent
331        // runner. Reconnect must see S3 only, never the ambiguous historical
332        // sequence S1,C1,S2,C2,S3.
333        for event in [
334            started("resident", "generation-1"),
335            completed("resident", "completed"),
336            started("resident", "generation-2"),
337            completed("resident", "completed"),
338            started("resident", "generation-3"),
339        ] {
340            runner.push_critical_event(event);
341        }
342
343        assert_eq!(runner.last_critical_events.len(), 1);
344        assert!(matches!(
345            &runner.last_critical_events[0],
346            AgentEvent::SubAgentStarted {
347                child_session_id,
348                title: Some(title),
349                ..
350            } if child_session_id == "resident" && title == "generation-3"
351        ));
352
353        runner.push_critical_event(completed("resident", "completed"));
354        assert_eq!(runner.last_critical_events.len(), 1);
355        assert!(matches!(
356            &runner.last_critical_events[0],
357            AgentEvent::SubAgentCompleted {
358                child_session_id,
359                status,
360                ..
361            } if child_session_id == "resident" && status == "completed"
362        ));
363    }
364
365    #[test]
366    fn subagent_coalescing_preserves_other_children_and_recency() {
367        let mut runner = AgentRunner::new();
368        runner.push_critical_event(started("child-a", "a1"));
369        runner.push_critical_event(started("child-b", "b1"));
370        runner.push_critical_event(completed("child-a", "completed"));
371
372        assert_eq!(runner.last_critical_events.len(), 2);
373        assert!(matches!(
374            &runner.last_critical_events[0],
375            AgentEvent::SubAgentStarted {
376                child_session_id, ..
377            } if child_session_id == "child-b"
378        ));
379        assert!(matches!(
380            &runner.last_critical_events[1],
381            AgentEvent::SubAgentCompleted {
382                child_session_id, ..
383            } if child_session_id == "child-a"
384        ));
385    }
386
387    #[test]
388    fn wrapped_legacy_lifecycle_replays_once_and_cannot_double_count_direct_lifecycle() {
389        let mut runner = AgentRunner::new();
390        let legacy_start = wrapped(started("child-a", "initial"));
391        assert!(legacy_start.is_replayable_session_state());
392        runner.push_critical_event(legacy_start);
393        runner.push_critical_event(started("child-a", "direct"));
394        assert_eq!(runner.last_critical_events.len(), 1);
395        assert!(matches!(
396            &runner.last_critical_events[0],
397            AgentEvent::SubAgentStarted { title: Some(title), .. } if title == "direct"
398        ));
399        runner.push_critical_event(wrapped(completed("child-a", "completed")));
400        assert_eq!(runner.last_critical_events.len(), 1);
401        assert!(matches!(
402            &runner.last_critical_events[0],
403            AgentEvent::SubAgentEvent { .. }
404        ));
405        assert!(runner.last_critical_events.len() <= AgentRunner::CRITICAL_EVENTS_CAPACITY);
406
407        let invalid = AgentEvent::SubAgentEvent {
408            parent_session_id: "root".into(),
409            child_session_id: "unrelated".into(),
410            event: Box::new(started("child-b", "forged")),
411        };
412        assert!(!invalid.is_replayable_session_state());
413        assert!(invalid.legacy_child_lifecycle_observation().is_none());
414    }
415}