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