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 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 senders_guard
94 .entry(session_id.to_string())
95 .or_insert_with(|| event_sender.clone());
96
97 ReserveOutcome::Reserved(reservation)
98}
99
100pub 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
127pub 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 runner.cancel_token.cancel();
139 runner.event_publication.retire().await;
140 }
141 runners.remove(session_id)
142}
143
144pub 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
153pub 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
166pub 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
189pub 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 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 let session_tx = get_or_create_event_sender(&senders, "s1").await;
370 senders.write().await.remove("s1");
372 assert!(senders.read().await.get("s1").is_none());
373
374 let reservation = try_reserve_runner(&runners, &senders, "s1", &session_tx).await;
376 assert!(reservation.is_some(), "reservation must succeed");
377
378 assert!(
381 senders.read().await.get("s1").is_some(),
382 "reservation must re-assert the evicted session sender into the map"
383 );
384
385 let subscriber_tx = get_or_create_event_sender(&senders, "s1").await;
388 let mut rx = subscriber_tx.subscribe();
389
390 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 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 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}