1use async_trait::async_trait;
9use bamboo_agent_core::AgentEvent;
10use bamboo_domain::Session;
11use tokio::sync::broadcast;
12
13use super::execute::has_pending_user_message;
14use super::types::{ResumeConfigSnapshot, ResumeOutcome};
15use crate::execution::{SessionExecutionReservation, SessionExecutionReserveOutcome};
16
17#[async_trait]
26pub trait ResumeExecutionPort: Send + Sync {
27 async fn load_session(&self, session_id: &str) -> Option<Session>;
29
30 async fn save_and_cache_session(&self, session: &mut Session);
36
37 async fn reserve_session_execution(
39 &self,
40 session_id: &str,
41 event_sender: &broadcast::Sender<AgentEvent>,
42 ) -> SessionExecutionReserveOutcome;
43
44 async fn prepare_response_execution(
47 &self,
48 _reservation: &mut SessionExecutionReservation,
49 ) -> std::io::Result<()> {
50 Ok(())
51 }
52
53 async fn get_or_create_event_sender(&self, session_id: &str) -> broadcast::Sender<AgentEvent>;
55
56 async fn spawn_resume_execution(&self, request: ResumeSpawnRequest);
61
62 #[doc(hidden)]
65 async fn spawn_configured_resume_execution(&self, request: ResumeSpawnRequest) {
66 self.spawn_resume_execution(request).await;
67 }
68
69 #[doc(hidden)]
71 #[allow(clippy::result_large_err)]
72 fn dispatch_configured_resume_execution(
73 &self,
74 request: ResumeSpawnRequest,
75 ) -> Result<(), ResumeSpawnRequest> {
76 self.dispatch_resume_execution(request)
77 }
78
79 #[allow(clippy::result_large_err)]
88 fn dispatch_resume_execution(
89 &self,
90 request: ResumeSpawnRequest,
91 ) -> Result<(), ResumeSpawnRequest> {
92 Err(request)
93 }
94}
95
96pub struct ResumeSpawnRequest {
105 pub session_id: String,
106 pub session: Session,
107 pub execution_reservation: SessionExecutionReservation,
108 pub event_sender: broadcast::Sender<AgentEvent>,
109 pub config: ResumeConfigSnapshot,
110}
111
112pub struct ResponseResumeHandoff {
116 execution_reservation: SessionExecutionReservation,
117 event_sender: broadcast::Sender<AgentEvent>,
118}
119
120impl ResponseResumeHandoff {
121 pub fn root_actor_writer(&self) -> Option<bamboo_domain::RootActorRuntimeWrite> {
122 self.execution_reservation.root_actor_writer()
123 }
124
125 pub fn execution_persistence(
127 &self,
128 ) -> Option<std::sync::Arc<dyn bamboo_domain::RuntimeSessionPersistence>> {
129 self.execution_reservation.execution_persistence()
130 }
131
132 pub fn subscribe(&self) -> broadcast::Receiver<AgentEvent> {
133 self.event_sender.subscribe()
134 }
135
136 pub fn into_parts(self) -> (SessionExecutionReservation, broadcast::Sender<AgentEvent>) {
139 (self.execution_reservation, self.event_sender)
140 }
141
142 pub fn publish_event(&mut self, event: AgentEvent) {
143 if let Some(event) = self.execution_reservation.queue_root_response_event(event) {
144 let _ = self.event_sender.send(event);
145 }
146 }
147
148 pub async fn abandon(self) {
149 self.execution_reservation.abandon().await;
150 }
151}
152
153pub async fn reserve_response_resume_handoff(
157 port: &dyn ResumeExecutionPort,
158 session_id: &str,
159 timeout: std::time::Duration,
160) -> Result<ResponseResumeHandoff, ResumeOutcome> {
161 let event_sender = port.get_or_create_event_sender(session_id).await;
162 let deadline = tokio::time::Instant::now() + timeout;
163 loop {
164 match port
165 .reserve_session_execution(session_id, &event_sender)
166 .await
167 {
168 SessionExecutionReserveOutcome::Reserved(mut execution_reservation) => {
169 if let Err(error) = port
170 .prepare_response_execution(&mut execution_reservation)
171 .await
172 {
173 let run_id = execution_reservation.run_id().to_string();
174 tracing::debug!(%session_id, %error, "response successor is not ready; question retained");
175 execution_reservation.abandon().await;
176 if tokio::time::Instant::now() >= deadline {
177 return Err(ResumeOutcome::AlreadyRunning { run_id });
178 }
179 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
180 continue;
181 }
182 return Ok(ResponseResumeHandoff {
183 execution_reservation,
184 event_sender,
185 });
186 }
187 SessionExecutionReserveOutcome::AlreadyRunning { run_id } => {
188 if tokio::time::Instant::now() >= deadline {
189 return Err(ResumeOutcome::AlreadyRunning { run_id });
190 }
191 }
192 }
193 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
194 }
195}
196
197pub async fn resume_session_execution_with_handoff(
200 port: &dyn ResumeExecutionPort,
201 session_id: &str,
202 session: Session,
203 config: ResumeConfigSnapshot,
204 handoff: ResponseResumeHandoff,
205) -> ResumeOutcome {
206 resume_with_handoff_origin(port, session_id, session, config, handoff, false).await
207}
208
209#[doc(hidden)]
211pub async fn resume_session_execution_with_handoff_and_configured_fallback(
212 port: &dyn ResumeExecutionPort,
213 session_id: &str,
214 session: Session,
215 config: ResumeConfigSnapshot,
216 handoff: ResponseResumeHandoff,
217) -> ResumeOutcome {
218 resume_with_handoff_origin(port, session_id, session, config, handoff, true).await
219}
220
221async fn resume_with_handoff_origin(
222 port: &dyn ResumeExecutionPort,
223 session_id: &str,
224 session: Session,
225 config: ResumeConfigSnapshot,
226 handoff: ResponseResumeHandoff,
227 from_config: bool,
228) -> ResumeOutcome {
229 if !has_pending_user_message(&session) {
230 tokio::spawn(async move {
231 handoff.abandon().await;
232 });
233 return ResumeOutcome::Completed;
234 }
235
236 let ResponseResumeHandoff {
237 execution_reservation,
238 event_sender,
239 } = handoff;
240 let run_id = execution_reservation.run_id().to_string();
241 let request = ResumeSpawnRequest {
242 session_id: session_id.to_string(),
243 session,
244 execution_reservation,
245 event_sender,
246 config,
247 };
248 let dispatched = if from_config {
249 port.dispatch_configured_resume_execution(request)
250 } else {
251 port.dispatch_resume_execution(request)
252 };
253 if let Err(request) = dispatched {
254 if from_config {
255 port.spawn_configured_resume_execution(request).await;
256 } else {
257 port.spawn_resume_execution(request).await;
258 }
259 }
260 ResumeOutcome::Started { run_id }
261}
262
263pub async fn resume_session_execution(
275 port: &dyn ResumeExecutionPort,
276 session_id: &str,
277 config: ResumeConfigSnapshot,
278) -> ResumeOutcome {
279 resume_session_execution_origin(port, session_id, config, false).await
280}
281
282#[doc(hidden)]
284pub async fn resume_session_execution_with_configured_fallback(
285 port: &dyn ResumeExecutionPort,
286 session_id: &str,
287 config: ResumeConfigSnapshot,
288) -> ResumeOutcome {
289 resume_session_execution_origin(port, session_id, config, true).await
290}
291
292async fn resume_session_execution_origin(
293 port: &dyn ResumeExecutionPort,
294 session_id: &str,
295 config: ResumeConfigSnapshot,
296 from_config: bool,
297) -> ResumeOutcome {
298 let Some(session) = port.load_session(session_id).await else {
300 return ResumeOutcome::NotFound;
301 };
302
303 if !has_pending_user_message(&session) {
304 return ResumeOutcome::Completed;
305 }
306
307 let event_sender = port.get_or_create_event_sender(session_id).await;
309 let reservation = match port
310 .reserve_session_execution(session_id, &event_sender)
311 .await
312 {
313 SessionExecutionReserveOutcome::Reserved(reservation) => reservation,
314 SessionExecutionReserveOutcome::AlreadyRunning { run_id } => {
315 return ResumeOutcome::AlreadyRunning { run_id };
316 }
317 };
318 let run_id = reservation.run_id().to_string();
319
320 let request = ResumeSpawnRequest {
321 session_id: session_id.to_string(),
322 session,
323 execution_reservation: reservation,
324 event_sender,
325 config,
326 };
327 if from_config {
328 port.spawn_configured_resume_execution(request).await;
329 } else {
330 port.spawn_resume_execution(request).await;
331 }
332
333 ResumeOutcome::Started { run_id }
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use crate::execution::{reserve_runner_core, AgentRunner, ReserveOutcome};
340 use bamboo_agent_core::Message;
341 use std::collections::{BTreeSet, HashMap};
342 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
343 use std::sync::Arc;
344 use tokio::sync::{Mutex, Notify, RwLock};
345
346 struct CancellingResumePort {
347 durable: Mutex<Session>,
348 runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
349 senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
350 spawn_entered: Arc<Notify>,
351 detached_release: Arc<Notify>,
352 detached_finished: Arc<AtomicBool>,
353 saw_marker_at_adapter: Arc<AtomicBool>,
354 used_configured_dispatch: Arc<AtomicBool>,
355 save_calls: AtomicUsize,
356 }
357
358 #[async_trait]
359 impl ResumeExecutionPort for CancellingResumePort {
360 async fn load_session(&self, _session_id: &str) -> Option<Session> {
361 Some(self.durable.lock().await.clone())
362 }
363
364 async fn save_and_cache_session(&self, session: &mut Session) {
365 self.save_calls.fetch_add(1, Ordering::SeqCst);
366 *self.durable.lock().await = session.clone();
367 }
368
369 async fn reserve_session_execution(
370 &self,
371 session_id: &str,
372 event_sender: &broadcast::Sender<AgentEvent>,
373 ) -> SessionExecutionReserveOutcome {
374 match reserve_runner_core(&self.runners, &self.senders, session_id, event_sender).await
375 {
376 ReserveOutcome::Reserved(reservation) => SessionExecutionReserveOutcome::Reserved(
377 SessionExecutionReservation::from_pending_registration(
378 session_id,
379 reservation,
380 None,
381 self.runners.clone(),
382 ),
383 ),
384 ReserveOutcome::AlreadyRunning(run_id) => {
385 SessionExecutionReserveOutcome::AlreadyRunning { run_id }
386 }
387 }
388 }
389
390 async fn get_or_create_event_sender(
391 &self,
392 session_id: &str,
393 ) -> broadcast::Sender<AgentEvent> {
394 if let Some(sender) = self.senders.read().await.get(session_id).cloned() {
395 return sender;
396 }
397 let sender = broadcast::channel(16).0;
398 self.senders
399 .write()
400 .await
401 .insert(session_id.to_string(), sender.clone());
402 sender
403 }
404
405 async fn spawn_resume_execution(&self, request: ResumeSpawnRequest) {
406 self.saw_marker_at_adapter.store(
407 request
408 .session
409 .metadata
410 .contains_key("execute.startup_handoff_at"),
411 Ordering::SeqCst,
412 );
413 self.spawn_entered.notify_one();
414 std::future::pending::<()>().await;
415 }
416
417 fn dispatch_configured_resume_execution(
418 &self,
419 request: ResumeSpawnRequest,
420 ) -> Result<(), ResumeSpawnRequest> {
421 self.used_configured_dispatch.store(true, Ordering::SeqCst);
422 self.dispatch_resume_execution(request)
423 }
424
425 fn dispatch_resume_execution(
426 &self,
427 request: ResumeSpawnRequest,
428 ) -> Result<(), ResumeSpawnRequest> {
429 self.saw_marker_at_adapter.store(
430 request
431 .session
432 .metadata
433 .contains_key("execute.startup_handoff_at"),
434 Ordering::SeqCst,
435 );
436 let spawn_entered = self.spawn_entered.clone();
437 let detached_release = self.detached_release.clone();
438 let detached_finished = self.detached_finished.clone();
439 tokio::spawn(async move {
440 let mut reservation = request.execution_reservation;
441 reservation
442 .ensure_registered()
443 .await
444 .expect("detached owner registers exact successor");
445 spawn_entered.notify_one();
446 detached_release.notified().await;
447 detached_finished.store(true, Ordering::SeqCst);
448 });
449 Ok(())
450 }
451 }
452
453 fn test_resume_config() -> ResumeConfigSnapshot {
454 ResumeConfigSnapshot {
455 provider_name: "test".to_string(),
456 provider_type: None,
457 fast_model: None,
458 fast_model_ref: None,
459 background_model: None,
460 background_model_ref: None,
461 background_model_provider: None,
462 summarization_model: None,
463 summarization_model_ref: None,
464 summarization_model_provider: None,
465 disabled_tools: BTreeSet::new(),
466 disabled_skill_ids: BTreeSet::new(),
467 image_fallback: None,
468 gold_config: None,
469 }
470 }
471
472 #[tokio::test]
473 async fn configured_handoff_uses_explicit_origin_and_keeps_detached_owner() {
474 let mut session = Session::new("resume-config-origin", "model");
475 session.add_message(Message::tool_result("call-1", "Selected response: A"));
476 session.metadata.insert(
477 "clarification_resume_pending".to_string(),
478 "true".to_string(),
479 );
480 let port = Arc::new(CancellingResumePort {
481 durable: Mutex::new(session),
482 runners: Arc::new(RwLock::new(HashMap::new())),
483 senders: Arc::new(RwLock::new(HashMap::new())),
484 spawn_entered: Arc::new(Notify::new()),
485 detached_release: Arc::new(Notify::new()),
486 detached_finished: Arc::new(AtomicBool::new(false)),
487 saw_marker_at_adapter: Arc::new(AtomicBool::new(false)),
488 used_configured_dispatch: Arc::new(AtomicBool::new(false)),
489 save_calls: AtomicUsize::new(0),
490 });
491 let handoff = reserve_response_resume_handoff(
492 port.as_ref(),
493 "resume-config-origin",
494 std::time::Duration::from_secs(1),
495 )
496 .await
497 .expect("reserve exact response successor");
498 let session = port.durable.lock().await.clone();
499 let outcome = resume_session_execution_with_handoff_and_configured_fallback(
500 port.as_ref(),
501 "resume-config-origin",
502 session,
503 test_resume_config(),
504 handoff,
505 )
506 .await;
507 assert!(matches!(outcome, ResumeOutcome::Started { .. }));
508 tokio::time::timeout(
509 std::time::Duration::from_secs(5),
510 port.spawn_entered.notified(),
511 )
512 .await
513 .expect("detached configured owner must register the successor");
514 assert!(port.used_configured_dispatch.load(Ordering::SeqCst));
515 port.detached_release.notify_one();
516 tokio::time::timeout(std::time::Duration::from_secs(5), async {
517 while !port.detached_finished.load(Ordering::SeqCst) {
518 tokio::task::yield_now().await;
519 }
520 })
521 .await
522 .expect("detached configured owner must finish");
523 }
524
525 #[tokio::test]
526 async fn caller_cancellation_after_dispatch_keeps_detached_resume_owner() {
527 let mut session = Session::new("resume-cancel", "model");
528 session.add_message(Message::tool_result("call-1", "Selected response: A"));
529 session.metadata.insert(
530 "clarification_resume_pending".to_string(),
531 "true".to_string(),
532 );
533 session.metadata.insert(
534 "execute.startup_handoff_at".to_string(),
535 "2026-08-10T00:00:00.000Z".to_string(),
536 );
537 let port = Arc::new(CancellingResumePort {
538 durable: Mutex::new(session),
539 runners: Arc::new(RwLock::new(HashMap::new())),
540 senders: Arc::new(RwLock::new(HashMap::new())),
541 spawn_entered: Arc::new(Notify::new()),
542 detached_release: Arc::new(Notify::new()),
543 detached_finished: Arc::new(AtomicBool::new(false)),
544 saw_marker_at_adapter: Arc::new(AtomicBool::new(false)),
545 used_configured_dispatch: Arc::new(AtomicBool::new(false)),
546 save_calls: AtomicUsize::new(0),
547 });
548
549 let handoff = reserve_response_resume_handoff(
550 port.as_ref(),
551 "resume-cancel",
552 std::time::Duration::from_secs(1),
553 )
554 .await
555 .expect("reserve exact response successor");
556 let committed_session = port.durable.lock().await.clone();
557 let task_port = port.clone();
558 let resume = tokio::spawn(async move {
559 let outcome = resume_session_execution_with_handoff(
560 task_port.as_ref(),
561 "resume-cancel",
562 committed_session,
563 test_resume_config(),
564 handoff,
565 )
566 .await;
567 assert!(matches!(outcome, ResumeOutcome::Started { .. }));
568 std::future::pending::<()>().await
569 });
570 tokio::time::timeout(
571 std::time::Duration::from_secs(5),
572 port.spawn_entered.notified(),
573 )
574 .await
575 .expect("detached resume owner must register the successor");
576 resume.abort();
577 let _ = resume.await;
578 port.detached_release.notify_one();
579 tokio::time::timeout(std::time::Duration::from_secs(5), async {
580 while !port.detached_finished.load(Ordering::SeqCst) {
581 tokio::task::yield_now().await;
582 }
583 })
584 .await
585 .expect("detached owner must survive caller cancellation");
586
587 assert!(port.saw_marker_at_adapter.load(Ordering::SeqCst));
588 assert!(!port.used_configured_dispatch.load(Ordering::SeqCst));
589 assert_eq!(port.save_calls.load(Ordering::SeqCst), 0);
590 let durable = port.durable.lock().await;
591 assert_eq!(
592 durable
593 .metadata
594 .get("clarification_resume_pending")
595 .map(String::as_str),
596 Some("true")
597 );
598 assert!(durable.metadata.contains_key("execute.startup_handoff_at"));
599 }
600}