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};
#[derive(Debug, Clone)]
pub struct RunnerReservation {
pub cancel_token: CancellationToken,
pub run_id: String,
}
#[derive(Debug, Clone)]
pub enum ReserveOutcome {
Reserved(RunnerReservation),
AlreadyRunning(String),
}
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 runners_guard = runners.write().await;
if let Some(runner) = runners_guard.get(session_id) {
if matches!(runner.status, AgentStatus::Running) {
return ReserveOutcome::AlreadyRunning(runner.run_id.clone());
}
}
let mut senders_guard = senders.write().await;
runners_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(),
};
runners_guard.insert(session_id.to_string(), runner);
senders_guard
.entry(session_id.to_string())
.or_insert_with(|| event_sender.clone());
ReserveOutcome::Reserved(reservation)
}
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
}
}
}
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()),
}
}
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());
}
}
pub async fn finalize_runner_exact(
runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
session_id: &str,
run_id: &str,
result: &Result<(), AgentError>,
) -> bool {
let mut guard = runners.write().await;
let Some(runner) = guard.get_mut(session_id) else {
return false;
};
if runner.run_id != run_id {
return false;
}
runner.status = status_from_execution_result(result);
runner.completed_at = Some(Utc::now());
true
}
pub async fn finalize_rejected_runner_if_distinct(
runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
session_id: &str,
existing_owner_run_id: &str,
attempted_run_id: &str,
result: &Result<(), AgentError>,
) -> bool {
if existing_owner_run_id == attempted_run_id {
return false;
}
finalize_runner_exact(runners, session_id, attempted_run_id, result).await
}
#[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 rejected_duplicate_does_not_terminalize_shared_owner_slot() {
let runners = new_runners();
let senders = new_senders();
let tx = new_broadcaster();
let reservation = try_reserve_runner(&runners, &senders, "s1", &tx)
.await
.unwrap();
let rejected = Err(AgentError::Cancelled);
assert!(
!finalize_rejected_runner_if_distinct(
&runners,
"s1",
&reservation.run_id,
&reservation.run_id,
&rejected,
)
.await
);
assert!(matches!(
runners.read().await.get("s1").map(|runner| &runner.status),
Some(AgentStatus::Running)
));
}
#[tokio::test]
async fn rejected_distinct_runner_releases_only_its_exact_slot() {
let runners = new_runners();
let senders = new_senders();
let tx = new_broadcaster();
let reservation = try_reserve_runner(&runners, &senders, "s1", &tx)
.await
.unwrap();
let rejected = Err(AgentError::Cancelled);
assert!(
finalize_rejected_runner_if_distinct(
&runners,
"s1",
"different-live-owner",
&reservation.run_id,
&rejected,
)
.await
);
assert!(matches!(
runners.read().await.get("s1").map(|runner| &runner.status),
Some(AgentStatus::Cancelled)
));
}
#[tokio::test]
async fn try_reserve_runner_reasserts_evicted_sender_so_late_subscriber_receives() {
use super::super::session_events::get_or_create_event_sender;
use bamboo_agent_core::AgentEvent;
let runners = new_runners();
let senders = new_senders();
let session_tx = get_or_create_event_sender(&senders, "s1").await;
senders.write().await.remove("s1");
assert!(senders.read().await.get("s1").is_none());
let reservation = try_reserve_runner(&runners, &senders, "s1", &session_tx).await;
assert!(reservation.is_some(), "reservation must succeed");
assert!(
senders.read().await.get("s1").is_some(),
"reservation must re-assert the evicted session sender into the map"
);
let subscriber_tx = get_or_create_event_sender(&senders, "s1").await;
let mut rx = subscriber_tx.subscribe();
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"
);
}
#[tokio::test]
async fn cancellation_before_atomic_registry_commit_leaves_no_zombie_runner() {
let runners = new_runners();
let senders = new_senders();
let tx = new_broadcaster();
let held_senders = senders.write().await;
let task = {
let runners = runners.clone();
let senders = senders.clone();
let tx = tx.clone();
tokio::spawn(
async move { reserve_runner_core(&runners, &senders, "cancelled", &tx).await },
)
};
for _ in 0..100 {
if runners.try_write().is_err() {
break;
}
tokio::task::yield_now().await;
}
assert!(
runners.try_write().is_err(),
"reservation never reached the sender-lock barrier"
);
task.abort();
assert!(task.await.unwrap_err().is_cancelled());
drop(held_senders);
assert!(
runners.read().await.get("cancelled").is_none(),
"a cancelled reservation must not leave a Running slot without a task"
);
assert!(matches!(
reserve_runner_core(&runners, &senders, "cancelled", &tx).await,
ReserveOutcome::Reserved(_)
));
}
#[test]
fn status_from_execution_result_maps_correctly() {
let ok_result: Result<(), AgentError> = Ok(());
assert!(matches!(
status_from_execution_result(&ok_result),
AgentStatus::Completed
));
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:?}"),
}
}
}