Skip to main content

bamboo_engine/runtime/execution/
runner_lifecycle.rs

1//! Runner lifecycle helpers for background agent execution.
2//!
3//! Provides shared implementations for:
4//! - Runner reservation (check existing → create new with cancel token)
5//! - Runner finalization (map execution result to `AgentStatus`)
6//! - Status mapping
7
8use std::collections::HashMap;
9use std::sync::Arc;
10
11use chrono::Utc;
12use tokio::sync::{broadcast, RwLock};
13use tokio_util::sync::CancellationToken;
14
15use bamboo_agent_core::{AgentError, AgentEvent};
16
17use super::runner_state::{AgentRunner, AgentStatus};
18
19/// Reservation result from [`reserve_runner_core`] / [`try_reserve_runner`].
20#[derive(Debug, Clone)]
21pub struct RunnerReservation {
22    pub cancel_token: CancellationToken,
23    pub run_id: String,
24}
25
26/// Outcome of the shared reservation core.
27#[derive(Debug, Clone)]
28pub enum ReserveOutcome {
29    /// A fresh runner was reserved (any stale runner was replaced).
30    Reserved(RunnerReservation),
31    /// A `Running` runner already exists for this session; carries its `run_id`
32    /// so the caller can correlate subsequent SSE/WS events.
33    AlreadyRunning(String),
34}
35
36/// Shared runner-reservation core used by BOTH the server's `reserve_runner`
37/// and the engine's [`try_reserve_runner`], so the idle-eviction sender
38/// re-assert can never drift between the two paths again (#346).
39///
40/// While holding the `runners` write lock it:
41/// 1. short-circuits with [`ReserveOutcome::AlreadyRunning`] if a `Running`
42///    runner already exists;
43/// 2. otherwise replaces any stale runner with a fresh `Running` one whose
44///    `event_sender` is `event_sender`;
45/// 3. **re-asserts `event_sender` into `senders`** (`entry().or_insert_with`),
46///    STILL holding the `runners` write lock.
47///
48/// Step 3 closes the idle-sweep TOCTOU: a caller obtains the sender via
49/// `get_or_create_event_sender` and then reserves, but the 60s idle sweep can
50/// evict that session's sender in between (it drops a terminal runner and its
51/// sender together under the same `runners` ⊃ `senders` lock order). Without the
52/// re-assert, a resumed / re-executed run would publish to a channel no longer
53/// registered in `senders`, and a later subscriber's `get_or_create_event_sender`
54/// would mint a *different* channel and silently miss every event of that run.
55/// Because both this re-assert and the sweep acquire `runners` first, they are
56/// mutually exclusive, and a freshly-inserted `Running` runner is never swept —
57/// so the re-asserted sender is durable for the run.
58pub async fn reserve_runner_core(
59    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
60    senders: &Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
61    session_id: &str,
62    event_sender: &broadcast::Sender<AgentEvent>,
63) -> ReserveOutcome {
64    let mut guard = runners.write().await;
65    if let Some(runner) = guard.get(session_id) {
66        if matches!(runner.status, AgentStatus::Running) {
67            return ReserveOutcome::AlreadyRunning(runner.run_id.clone());
68        }
69    }
70
71    guard.remove(session_id);
72
73    let mut runner = AgentRunner::new();
74    runner.status = AgentStatus::Running;
75    runner.event_sender = event_sender.clone();
76    let reservation = RunnerReservation {
77        cancel_token: runner.cancel_token.clone(),
78        run_id: runner.run_id.clone(),
79    };
80    guard.insert(session_id.to_string(), runner);
81
82    // (3) Re-assert the session sender under the held `runners` write lock. Same
83    // channel as `event_sender`, so a no-op in the common case; restores the map
84    // entry if the idle sweep removed it. Lock order (runners ⊃ senders) matches
85    // the sweep, so this cannot deadlock.
86    senders
87        .write()
88        .await
89        .entry(session_id.to_string())
90        .or_insert_with(|| event_sender.clone());
91
92    ReserveOutcome::Reserved(reservation)
93}
94
95/// Try to reserve a runner for the given session.
96///
97/// Returns `None` if a `Running` runner already exists (the caller skips
98/// execution and surfaces the existing `run_id` separately). Otherwise reserves
99/// a fresh runner AND re-asserts the session sender into `senders` — see
100/// [`reserve_runner_core`].
101///
102/// The re-assert is **required, not optional**: this function is reached by the
103/// resume paths (`/respond`, child-completion coordinator, gold auto-answer via
104/// `resume_session_execution`), which re-execute a long-lived session under the
105/// SAME id after a wait that easily exceeds the idle TTL — exactly the
106/// evict-then-re-execute race #346's sweep newly opens.
107pub async fn try_reserve_runner(
108    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
109    senders: &Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
110    session_id: &str,
111    event_sender: &broadcast::Sender<AgentEvent>,
112) -> Option<RunnerReservation> {
113    match reserve_runner_core(runners, senders, session_id, event_sender).await {
114        ReserveOutcome::Reserved(reservation) => Some(reservation),
115        ReserveOutcome::AlreadyRunning(_) => {
116            tracing::debug!("[{}] Runner already running, skipping", session_id);
117            None
118        }
119    }
120}
121
122/// Map an execution result to `AgentStatus`.
123pub fn status_from_execution_result(result: &Result<(), AgentError>) -> AgentStatus {
124    match result {
125        Ok(_) => AgentStatus::Completed,
126        Err(error) if error.is_cancelled() => AgentStatus::Cancelled,
127        Err(error) => AgentStatus::Error(error.to_string()),
128    }
129}
130
131/// Update a runner's terminal status and completion timestamp.
132pub async fn finalize_runner(
133    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
134    session_id: &str,
135    result: &Result<(), AgentError>,
136) {
137    let mut guard = runners.write().await;
138    if let Some(runner) = guard.get_mut(session_id) {
139        runner.status = status_from_execution_result(result);
140        runner.completed_at = Some(Utc::now());
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    fn new_runners() -> Arc<RwLock<HashMap<String, AgentRunner>>> {
149        Arc::new(RwLock::new(HashMap::new()))
150    }
151
152    fn new_senders() -> Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>> {
153        Arc::new(RwLock::new(HashMap::new()))
154    }
155
156    fn new_broadcaster() -> broadcast::Sender<AgentEvent> {
157        broadcast::channel(100).0
158    }
159
160    #[tokio::test]
161    async fn try_reserve_runner_creates_runner_with_running_status() {
162        let runners = new_runners();
163        let senders = new_senders();
164        let tx = new_broadcaster();
165        let token = try_reserve_runner(&runners, &senders, "s1", &tx).await;
166        assert!(token.is_some());
167
168        let guard = runners.read().await;
169        let runner = guard.get("s1").unwrap();
170        assert!(matches!(runner.status, AgentStatus::Running));
171    }
172
173    #[tokio::test]
174    async fn try_reserve_runner_returns_none_when_already_running() {
175        let runners = new_runners();
176        let senders = new_senders();
177        let tx = new_broadcaster();
178        let _ = try_reserve_runner(&runners, &senders, "s1", &tx).await;
179        let second = try_reserve_runner(&runners, &senders, "s1", &tx).await;
180        assert!(second.is_none());
181    }
182
183    #[tokio::test]
184    async fn try_reserve_runner_replaces_completed_runner() {
185        let runners = new_runners();
186        let senders = new_senders();
187        let tx = new_broadcaster();
188        let _ = try_reserve_runner(&runners, &senders, "s1", &tx).await;
189
190        {
191            let mut guard = runners.write().await;
192            let runner = guard.get_mut("s1").unwrap();
193            runner.status = AgentStatus::Completed;
194        }
195
196        let second = try_reserve_runner(&runners, &senders, "s1", &tx).await;
197        assert!(second.is_some());
198    }
199
200    #[tokio::test]
201    async fn try_reserve_runner_reasserts_evicted_sender_so_late_subscriber_receives() {
202        // Regression for #346: the resume path (`resume_session_execution`)
203        // obtains the session sender, then reserves — and the idle sweep can
204        // evict that sender in between. `try_reserve_runner` must re-assert it so
205        // the resumed run's channel stays registered and a late subscriber lands
206        // on the SAME channel the run publishes to.
207        use super::super::session_events::get_or_create_event_sender;
208        use bamboo_agent_core::AgentEvent;
209
210        let runners = new_runners();
211        let senders = new_senders();
212
213        // Resume ordering: obtain sender (inserts into the map) ...
214        let session_tx = get_or_create_event_sender(&senders, "s1").await;
215        // ... then the idle sweep evicts this session's sender before reservation.
216        senders.write().await.remove("s1");
217        assert!(senders.read().await.get("s1").is_none());
218
219        // Reserve with the clone obtained before the eviction.
220        let reservation = try_reserve_runner(&runners, &senders, "s1", &session_tx).await;
221        assert!(reservation.is_some(), "reservation must succeed");
222
223        // The sender MUST be re-asserted into the map. Without the fix it is
224        // absent here and the run's channel is orphaned.
225        assert!(
226            senders.read().await.get("s1").is_some(),
227            "reservation must re-assert the evicted session sender into the map"
228        );
229
230        // A late subscriber (SSE/WS handler: get_or_create then subscribe) must
231        // land on the SAME channel the run publishes to.
232        let subscriber_tx = get_or_create_event_sender(&senders, "s1").await;
233        let mut rx = subscriber_tx.subscribe();
234
235        // The resumed run publishes via its reserved channel (== session_tx).
236        let _ = session_tx.send(AgentEvent::SessionDeleted {
237            session_id: "s1".to_string(),
238        });
239
240        let received = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await;
241        assert!(
242            matches!(received, Ok(Ok(_))),
243            "late subscriber must receive events from the resumed run; without the \
244             re-assert, get_or_create mints a fresh channel and the event is lost"
245        );
246    }
247
248    #[test]
249    fn status_from_execution_result_maps_correctly() {
250        let ok_result: Result<(), AgentError> = Ok(());
251        assert!(matches!(
252            status_from_execution_result(&ok_result),
253            AgentStatus::Completed
254        ));
255
256        // Cancellation is detected by matching the `AgentError::Cancelled`
257        // variant, not by substring-matching the (display) message — note the
258        // variant's message is "Cancelled", which would not even contain the
259        // lowercase "cancelled" the old code searched for.
260        let cancelled: Result<(), AgentError> = Err(AgentError::Cancelled);
261        assert!(matches!(
262            status_from_execution_result(&cancelled),
263            AgentStatus::Cancelled
264        ));
265
266        let failed: Result<(), AgentError> = Err(AgentError::LLM("network error".to_string()));
267        match status_from_execution_result(&failed) {
268            AgentStatus::Error(message) => assert!(message.contains("network error")),
269            other => panic!("unexpected status: {other:?}"),
270        }
271    }
272}