bamboo-engine 2026.7.12

Execution engine and orchestration for the Bamboo agent framework
Documentation
//! Runner lifecycle helpers for background agent execution.
//!
//! Provides shared implementations for:
//! - Runner reservation (check existing → create new with cancel token)
//! - Runner finalization (map execution result to `AgentStatus`)
//! - Status mapping

use std::collections::HashMap;
use std::sync::Arc;

use chrono::Utc;
use tokio::sync::{broadcast, RwLock};
use tokio_util::sync::CancellationToken;

use bamboo_agent_core::{AgentError, AgentEvent};

use super::runner_state::{AgentRunner, AgentStatus};

/// Reservation result from [`reserve_runner_core`] / [`try_reserve_runner`].
#[derive(Debug, Clone)]
pub struct RunnerReservation {
    pub cancel_token: CancellationToken,
    pub run_id: String,
}

/// Outcome of the shared reservation core.
#[derive(Debug, Clone)]
pub enum ReserveOutcome {
    /// A fresh runner was reserved (any stale runner was replaced).
    Reserved(RunnerReservation),
    /// A `Running` runner already exists for this session; carries its `run_id`
    /// so the caller can correlate subsequent SSE/WS events.
    AlreadyRunning(String),
}

/// Shared runner-reservation core used by BOTH the server's `reserve_runner`
/// and the engine's [`try_reserve_runner`], so the idle-eviction sender
/// re-assert can never drift between the two paths again (#346).
///
/// While holding the `runners` write lock it:
/// 1. short-circuits with [`ReserveOutcome::AlreadyRunning`] if a `Running`
///    runner already exists;
/// 2. otherwise replaces any stale runner with a fresh `Running` one whose
///    `event_sender` is `event_sender`;
/// 3. **re-asserts `event_sender` into `senders`** (`entry().or_insert_with`),
///    STILL holding the `runners` write lock.
///
/// Step 3 closes the idle-sweep TOCTOU: a caller obtains the sender via
/// `get_or_create_event_sender` and then reserves, but the 60s idle sweep can
/// evict that session's sender in between (it drops a terminal runner and its
/// sender together under the same `runners` ⊃ `senders` lock order). Without the
/// re-assert, a resumed / re-executed run would publish to a channel no longer
/// registered in `senders`, and a later subscriber's `get_or_create_event_sender`
/// would mint a *different* channel and silently miss every event of that run.
/// Because both this re-assert and the sweep acquire `runners` first, they are
/// mutually exclusive, and a freshly-inserted `Running` runner is never swept —
/// so the re-asserted sender is durable for the run.
pub async fn reserve_runner_core(
    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
    senders: &Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
    session_id: &str,
    event_sender: &broadcast::Sender<AgentEvent>,
) -> ReserveOutcome {
    let mut guard = runners.write().await;
    if let Some(runner) = guard.get(session_id) {
        if matches!(runner.status, AgentStatus::Running) {
            return ReserveOutcome::AlreadyRunning(runner.run_id.clone());
        }
    }

    guard.remove(session_id);

    let mut runner = AgentRunner::new();
    runner.status = AgentStatus::Running;
    runner.event_sender = event_sender.clone();
    let reservation = RunnerReservation {
        cancel_token: runner.cancel_token.clone(),
        run_id: runner.run_id.clone(),
    };
    guard.insert(session_id.to_string(), runner);

    // (3) Re-assert the session sender under the held `runners` write lock. Same
    // channel as `event_sender`, so a no-op in the common case; restores the map
    // entry if the idle sweep removed it. Lock order (runners ⊃ senders) matches
    // the sweep, so this cannot deadlock.
    senders
        .write()
        .await
        .entry(session_id.to_string())
        .or_insert_with(|| event_sender.clone());

    ReserveOutcome::Reserved(reservation)
}

/// Try to reserve a runner for the given session.
///
/// Returns `None` if a `Running` runner already exists (the caller skips
/// execution and surfaces the existing `run_id` separately). Otherwise reserves
/// a fresh runner AND re-asserts the session sender into `senders` — see
/// [`reserve_runner_core`].
///
/// The re-assert is **required, not optional**: this function is reached by the
/// resume paths (`/respond`, child-completion coordinator, gold auto-answer via
/// `resume_session_execution`), which re-execute a long-lived session under the
/// SAME id after a wait that easily exceeds the idle TTL — exactly the
/// evict-then-re-execute race #346's sweep newly opens.
pub async fn try_reserve_runner(
    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
    senders: &Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
    session_id: &str,
    event_sender: &broadcast::Sender<AgentEvent>,
) -> Option<RunnerReservation> {
    match reserve_runner_core(runners, senders, session_id, event_sender).await {
        ReserveOutcome::Reserved(reservation) => Some(reservation),
        ReserveOutcome::AlreadyRunning(_) => {
            tracing::debug!("[{}] Runner already running, skipping", session_id);
            None
        }
    }
}

/// Map an execution result to `AgentStatus`.
pub fn status_from_execution_result(result: &Result<(), AgentError>) -> AgentStatus {
    match result {
        Ok(_) => AgentStatus::Completed,
        Err(error) if error.is_cancelled() => AgentStatus::Cancelled,
        Err(error) => AgentStatus::Error(error.to_string()),
    }
}

/// Update a runner's terminal status and completion timestamp.
pub async fn finalize_runner(
    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
    session_id: &str,
    result: &Result<(), AgentError>,
) {
    let mut guard = runners.write().await;
    if let Some(runner) = guard.get_mut(session_id) {
        runner.status = status_from_execution_result(result);
        runner.completed_at = Some(Utc::now());
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn new_runners() -> Arc<RwLock<HashMap<String, AgentRunner>>> {
        Arc::new(RwLock::new(HashMap::new()))
    }

    fn new_senders() -> Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>> {
        Arc::new(RwLock::new(HashMap::new()))
    }

    fn new_broadcaster() -> broadcast::Sender<AgentEvent> {
        broadcast::channel(100).0
    }

    #[tokio::test]
    async fn try_reserve_runner_creates_runner_with_running_status() {
        let runners = new_runners();
        let senders = new_senders();
        let tx = new_broadcaster();
        let token = try_reserve_runner(&runners, &senders, "s1", &tx).await;
        assert!(token.is_some());

        let guard = runners.read().await;
        let runner = guard.get("s1").unwrap();
        assert!(matches!(runner.status, AgentStatus::Running));
    }

    #[tokio::test]
    async fn try_reserve_runner_returns_none_when_already_running() {
        let runners = new_runners();
        let senders = new_senders();
        let tx = new_broadcaster();
        let _ = try_reserve_runner(&runners, &senders, "s1", &tx).await;
        let second = try_reserve_runner(&runners, &senders, "s1", &tx).await;
        assert!(second.is_none());
    }

    #[tokio::test]
    async fn try_reserve_runner_replaces_completed_runner() {
        let runners = new_runners();
        let senders = new_senders();
        let tx = new_broadcaster();
        let _ = try_reserve_runner(&runners, &senders, "s1", &tx).await;

        {
            let mut guard = runners.write().await;
            let runner = guard.get_mut("s1").unwrap();
            runner.status = AgentStatus::Completed;
        }

        let second = try_reserve_runner(&runners, &senders, "s1", &tx).await;
        assert!(second.is_some());
    }

    #[tokio::test]
    async fn try_reserve_runner_reasserts_evicted_sender_so_late_subscriber_receives() {
        // Regression for #346: the resume path (`resume_session_execution`)
        // obtains the session sender, then reserves — and the idle sweep can
        // evict that sender in between. `try_reserve_runner` must re-assert it so
        // the resumed run's channel stays registered and a late subscriber lands
        // on the SAME channel the run publishes to.
        use super::super::session_events::get_or_create_event_sender;
        use bamboo_agent_core::AgentEvent;

        let runners = new_runners();
        let senders = new_senders();

        // Resume ordering: obtain sender (inserts into the map) ...
        let session_tx = get_or_create_event_sender(&senders, "s1").await;
        // ... then the idle sweep evicts this session's sender before reservation.
        senders.write().await.remove("s1");
        assert!(senders.read().await.get("s1").is_none());

        // Reserve with the clone obtained before the eviction.
        let reservation = try_reserve_runner(&runners, &senders, "s1", &session_tx).await;
        assert!(reservation.is_some(), "reservation must succeed");

        // The sender MUST be re-asserted into the map. Without the fix it is
        // absent here and the run's channel is orphaned.
        assert!(
            senders.read().await.get("s1").is_some(),
            "reservation must re-assert the evicted session sender into the map"
        );

        // A late subscriber (SSE/WS handler: get_or_create then subscribe) must
        // land on the SAME channel the run publishes to.
        let subscriber_tx = get_or_create_event_sender(&senders, "s1").await;
        let mut rx = subscriber_tx.subscribe();

        // The resumed run publishes via its reserved channel (== session_tx).
        let _ = session_tx.send(AgentEvent::SessionDeleted {
            session_id: "s1".to_string(),
        });

        let received = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await;
        assert!(
            matches!(received, Ok(Ok(_))),
            "late subscriber must receive events from the resumed run; without the \
             re-assert, get_or_create mints a fresh channel and the event is lost"
        );
    }

    #[test]
    fn status_from_execution_result_maps_correctly() {
        let ok_result: Result<(), AgentError> = Ok(());
        assert!(matches!(
            status_from_execution_result(&ok_result),
            AgentStatus::Completed
        ));

        // Cancellation is detected by matching the `AgentError::Cancelled`
        // variant, not by substring-matching the (display) message — note the
        // variant's message is "Cancelled", which would not even contain the
        // lowercase "cancelled" the old code searched for.
        let cancelled: Result<(), AgentError> = Err(AgentError::Cancelled);
        assert!(matches!(
            status_from_execution_result(&cancelled),
            AgentStatus::Cancelled
        ));

        let failed: Result<(), AgentError> = Err(AgentError::LLM("network error".to_string()));
        match status_from_execution_result(&failed) {
            AgentStatus::Error(message) => assert!(message.contains("network error")),
            other => panic!("unexpected status: {other:?}"),
        }
    }
}