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 runners_guard = runners.write().await;
65    if let Some(runner) = runners_guard.get(session_id) {
66        if matches!(runner.status, AgentStatus::Running) {
67            return ReserveOutcome::AlreadyRunning(runner.run_id.clone());
68        }
69    }
70
71    // Acquire every fallible/cancellation point before mutating the runner
72    // registry. In particular, cancellation while waiting for the sender map
73    // must not leave a `Running` slot with no task behind it.
74    let mut senders_guard = senders.write().await;
75    runners_guard.remove(session_id);
76
77    let mut runner = AgentRunner::new();
78    runner.status = AgentStatus::Running;
79    runner.event_sender = event_sender.clone();
80    let reservation = RunnerReservation {
81        cancel_token: runner.cancel_token.clone(),
82        run_id: runner.run_id.clone(),
83    };
84    runners_guard.insert(session_id.to_string(), runner);
85
86    // (3) Re-assert the session sender under the held `runners` write lock. Same
87    // channel as `event_sender`, so a no-op in the common case; restores the map
88    // entry if the idle sweep removed it. Lock order (runners ⊃ senders) matches
89    // the sweep, so this cannot deadlock.
90    senders_guard
91        .entry(session_id.to_string())
92        .or_insert_with(|| event_sender.clone());
93
94    ReserveOutcome::Reserved(reservation)
95}
96
97/// Try to reserve a runner for the given session.
98///
99/// Returns `None` if a `Running` runner already exists (the caller skips
100/// execution and surfaces the existing `run_id` separately). Otherwise reserves
101/// a fresh runner AND re-asserts the session sender into `senders` — see
102/// [`reserve_runner_core`].
103///
104/// The re-assert is **required, not optional**: this function is reached by the
105/// resume paths (`/respond`, child-completion coordinator, gold auto-answer via
106/// `resume_session_execution`), which re-execute a long-lived session under the
107/// SAME id after a wait that easily exceeds the idle TTL — exactly the
108/// evict-then-re-execute race #346's sweep newly opens.
109pub async fn try_reserve_runner(
110    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
111    senders: &Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
112    session_id: &str,
113    event_sender: &broadcast::Sender<AgentEvent>,
114) -> Option<RunnerReservation> {
115    match reserve_runner_core(runners, senders, session_id, event_sender).await {
116        ReserveOutcome::Reserved(reservation) => Some(reservation),
117        ReserveOutcome::AlreadyRunning(_) => {
118            tracing::debug!("[{}] Runner already running, skipping", session_id);
119            None
120        }
121    }
122}
123
124/// Map an execution result to `AgentStatus`.
125pub fn status_from_execution_result(result: &Result<(), AgentError>) -> AgentStatus {
126    match result {
127        Ok(_) => AgentStatus::Completed,
128        Err(error) if error.is_cancelled() => AgentStatus::Cancelled,
129        Err(error) => AgentStatus::Error(error.to_string()),
130    }
131}
132
133/// Update a runner's terminal status and completion timestamp.
134pub async fn finalize_runner(
135    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
136    session_id: &str,
137    result: &Result<(), AgentError>,
138) {
139    let mut guard = runners.write().await;
140    if let Some(runner) = guard.get_mut(session_id) {
141        runner.status = status_from_execution_result(result);
142        runner.completed_at = Some(Utc::now());
143    }
144}
145
146/// Finalize only the exact reserved runner.
147///
148/// Registration collisions can expose a stale caller after another entry
149/// point has become authoritative. That caller must release its own slot
150/// without ever terminalizing a newer runner stored under the same session id.
151pub async fn finalize_runner_exact(
152    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
153    session_id: &str,
154    run_id: &str,
155    result: &Result<(), AgentError>,
156) -> bool {
157    let mut guard = runners.write().await;
158    let Some(runner) = guard.get_mut(session_id) else {
159        return false;
160    };
161    if runner.run_id != run_id {
162        return false;
163    }
164    runner.status = status_from_execution_result(result);
165    runner.completed_at = Some(Utc::now());
166    true
167}
168
169/// Finalize a rejected caller only when it owns a distinct runner slot.
170///
171/// A duplicate task can race with the live task while both observe the same
172/// registry run id. Rejecting that duplicate must not terminalize the slot that
173/// still belongs to the registered owner.
174pub async fn finalize_rejected_runner_if_distinct(
175    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
176    session_id: &str,
177    existing_owner_run_id: &str,
178    attempted_run_id: &str,
179    result: &Result<(), AgentError>,
180) -> bool {
181    if existing_owner_run_id == attempted_run_id {
182        return false;
183    }
184    finalize_runner_exact(runners, session_id, attempted_run_id, result).await
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    fn new_runners() -> Arc<RwLock<HashMap<String, AgentRunner>>> {
192        Arc::new(RwLock::new(HashMap::new()))
193    }
194
195    fn new_senders() -> Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>> {
196        Arc::new(RwLock::new(HashMap::new()))
197    }
198
199    fn new_broadcaster() -> broadcast::Sender<AgentEvent> {
200        broadcast::channel(100).0
201    }
202
203    #[tokio::test]
204    async fn try_reserve_runner_creates_runner_with_running_status() {
205        let runners = new_runners();
206        let senders = new_senders();
207        let tx = new_broadcaster();
208        let token = try_reserve_runner(&runners, &senders, "s1", &tx).await;
209        assert!(token.is_some());
210
211        let guard = runners.read().await;
212        let runner = guard.get("s1").unwrap();
213        assert!(matches!(runner.status, AgentStatus::Running));
214    }
215
216    #[tokio::test]
217    async fn try_reserve_runner_returns_none_when_already_running() {
218        let runners = new_runners();
219        let senders = new_senders();
220        let tx = new_broadcaster();
221        let _ = try_reserve_runner(&runners, &senders, "s1", &tx).await;
222        let second = try_reserve_runner(&runners, &senders, "s1", &tx).await;
223        assert!(second.is_none());
224    }
225
226    #[tokio::test]
227    async fn try_reserve_runner_replaces_completed_runner() {
228        let runners = new_runners();
229        let senders = new_senders();
230        let tx = new_broadcaster();
231        let _ = try_reserve_runner(&runners, &senders, "s1", &tx).await;
232
233        {
234            let mut guard = runners.write().await;
235            let runner = guard.get_mut("s1").unwrap();
236            runner.status = AgentStatus::Completed;
237        }
238
239        let second = try_reserve_runner(&runners, &senders, "s1", &tx).await;
240        assert!(second.is_some());
241    }
242
243    #[tokio::test]
244    async fn rejected_duplicate_does_not_terminalize_shared_owner_slot() {
245        let runners = new_runners();
246        let senders = new_senders();
247        let tx = new_broadcaster();
248        let reservation = try_reserve_runner(&runners, &senders, "s1", &tx)
249            .await
250            .unwrap();
251        let rejected = Err(AgentError::Cancelled);
252
253        assert!(
254            !finalize_rejected_runner_if_distinct(
255                &runners,
256                "s1",
257                &reservation.run_id,
258                &reservation.run_id,
259                &rejected,
260            )
261            .await
262        );
263        assert!(matches!(
264            runners.read().await.get("s1").map(|runner| &runner.status),
265            Some(AgentStatus::Running)
266        ));
267    }
268
269    #[tokio::test]
270    async fn rejected_distinct_runner_releases_only_its_exact_slot() {
271        let runners = new_runners();
272        let senders = new_senders();
273        let tx = new_broadcaster();
274        let reservation = try_reserve_runner(&runners, &senders, "s1", &tx)
275            .await
276            .unwrap();
277        let rejected = Err(AgentError::Cancelled);
278
279        assert!(
280            finalize_rejected_runner_if_distinct(
281                &runners,
282                "s1",
283                "different-live-owner",
284                &reservation.run_id,
285                &rejected,
286            )
287            .await
288        );
289        assert!(matches!(
290            runners.read().await.get("s1").map(|runner| &runner.status),
291            Some(AgentStatus::Cancelled)
292        ));
293    }
294
295    #[tokio::test]
296    async fn try_reserve_runner_reasserts_evicted_sender_so_late_subscriber_receives() {
297        // Regression for #346: the resume path (`resume_session_execution`)
298        // obtains the session sender, then reserves — and the idle sweep can
299        // evict that sender in between. `try_reserve_runner` must re-assert it so
300        // the resumed run's channel stays registered and a late subscriber lands
301        // on the SAME channel the run publishes to.
302        use super::super::session_events::get_or_create_event_sender;
303        use bamboo_agent_core::AgentEvent;
304
305        let runners = new_runners();
306        let senders = new_senders();
307
308        // Resume ordering: obtain sender (inserts into the map) ...
309        let session_tx = get_or_create_event_sender(&senders, "s1").await;
310        // ... then the idle sweep evicts this session's sender before reservation.
311        senders.write().await.remove("s1");
312        assert!(senders.read().await.get("s1").is_none());
313
314        // Reserve with the clone obtained before the eviction.
315        let reservation = try_reserve_runner(&runners, &senders, "s1", &session_tx).await;
316        assert!(reservation.is_some(), "reservation must succeed");
317
318        // The sender MUST be re-asserted into the map. Without the fix it is
319        // absent here and the run's channel is orphaned.
320        assert!(
321            senders.read().await.get("s1").is_some(),
322            "reservation must re-assert the evicted session sender into the map"
323        );
324
325        // A late subscriber (SSE/WS handler: get_or_create then subscribe) must
326        // land on the SAME channel the run publishes to.
327        let subscriber_tx = get_or_create_event_sender(&senders, "s1").await;
328        let mut rx = subscriber_tx.subscribe();
329
330        // The resumed run publishes via its reserved channel (== session_tx).
331        let _ = session_tx.send(AgentEvent::SessionDeleted {
332            session_id: "s1".to_string(),
333        });
334
335        let received = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await;
336        assert!(
337            matches!(received, Ok(Ok(_))),
338            "late subscriber must receive events from the resumed run; without the \
339             re-assert, get_or_create mints a fresh channel and the event is lost"
340        );
341    }
342
343    #[tokio::test]
344    async fn cancellation_before_atomic_registry_commit_leaves_no_zombie_runner() {
345        let runners = new_runners();
346        let senders = new_senders();
347        let tx = new_broadcaster();
348        let held_senders = senders.write().await;
349
350        let task = {
351            let runners = runners.clone();
352            let senders = senders.clone();
353            let tx = tx.clone();
354            tokio::spawn(
355                async move { reserve_runner_core(&runners, &senders, "cancelled", &tx).await },
356            )
357        };
358
359        // Wait until the reservation owns the runners lock and is blocked on
360        // the sender lock. No registry mutation is allowed before that final
361        // cancellation point completes.
362        for _ in 0..100 {
363            if runners.try_write().is_err() {
364                break;
365            }
366            tokio::task::yield_now().await;
367        }
368        assert!(
369            runners.try_write().is_err(),
370            "reservation never reached the sender-lock barrier"
371        );
372        task.abort();
373        assert!(task.await.unwrap_err().is_cancelled());
374        drop(held_senders);
375
376        assert!(
377            runners.read().await.get("cancelled").is_none(),
378            "a cancelled reservation must not leave a Running slot without a task"
379        );
380        assert!(matches!(
381            reserve_runner_core(&runners, &senders, "cancelled", &tx).await,
382            ReserveOutcome::Reserved(_)
383        ));
384    }
385
386    #[test]
387    fn status_from_execution_result_maps_correctly() {
388        let ok_result: Result<(), AgentError> = Ok(());
389        assert!(matches!(
390            status_from_execution_result(&ok_result),
391            AgentStatus::Completed
392        ));
393
394        // Cancellation is detected by matching the `AgentError::Cancelled`
395        // variant, not by substring-matching the (display) message — note the
396        // variant's message is "Cancelled", which would not even contain the
397        // lowercase "cancelled" the old code searched for.
398        let cancelled: Result<(), AgentError> = Err(AgentError::Cancelled);
399        assert!(matches!(
400            status_from_execution_result(&cancelled),
401            AgentStatus::Cancelled
402        ));
403
404        let failed: Result<(), AgentError> = Err(AgentError::LLM("network error".to_string()));
405        match status_from_execution_result(&failed) {
406            AgentStatus::Error(message) => assert!(message.contains("network error")),
407            other => panic!("unexpected status: {other:?}"),
408        }
409    }
410}