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