bamboo_engine/runtime/execution/
runner_lifecycle.rs1use 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#[derive(Debug, Clone)]
21pub struct RunnerReservation {
22 pub cancel_token: CancellationToken,
23 pub run_id: String,
24}
25
26#[derive(Debug, Clone)]
28pub enum ReserveOutcome {
29 Reserved(RunnerReservation),
31 AlreadyRunning(String),
34}
35
36pub 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 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 senders_guard
91 .entry(session_id.to_string())
92 .or_insert_with(|| event_sender.clone());
93
94 ReserveOutcome::Reserved(reservation)
95}
96
97pub 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
124pub 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
133pub 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
146pub 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
169pub 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 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 let session_tx = get_or_create_event_sender(&senders, "s1").await;
310 senders.write().await.remove("s1");
312 assert!(senders.read().await.get("s1").is_none());
313
314 let reservation = try_reserve_runner(&runners, &senders, "s1", &session_tx).await;
316 assert!(reservation.is_some(), "reservation must succeed");
317
318 assert!(
321 senders.read().await.get("s1").is_some(),
322 "reservation must re-assert the evicted session sender into the map"
323 );
324
325 let subscriber_tx = get_or_create_event_sender(&senders, "s1").await;
328 let mut rx = subscriber_tx.subscribe();
329
330 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 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 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}