bamboo_engine/session_app/
resume.rs1use 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 #[allow(clippy::result_large_err)]
71 fn dispatch_resume_execution(
72 &self,
73 request: ResumeSpawnRequest,
74 ) -> Result<(), ResumeSpawnRequest> {
75 Err(request)
76 }
77}
78
79pub struct ResumeSpawnRequest {
88 pub session_id: String,
89 pub session: Session,
90 pub execution_reservation: SessionExecutionReservation,
91 pub event_sender: broadcast::Sender<AgentEvent>,
92 pub config: ResumeConfigSnapshot,
93}
94
95pub struct ResponseResumeHandoff {
99 execution_reservation: SessionExecutionReservation,
100 event_sender: broadcast::Sender<AgentEvent>,
101}
102
103impl ResponseResumeHandoff {
104 pub fn root_actor_writer(&self) -> Option<bamboo_domain::RootActorRuntimeWrite> {
105 self.execution_reservation.root_actor_writer()
106 }
107
108 pub fn subscribe(&self) -> broadcast::Receiver<AgentEvent> {
109 self.event_sender.subscribe()
110 }
111
112 pub fn into_parts(self) -> (SessionExecutionReservation, broadcast::Sender<AgentEvent>) {
115 (self.execution_reservation, self.event_sender)
116 }
117
118 pub fn publish_event(&mut self, event: AgentEvent) {
119 if let Some(event) = self.execution_reservation.queue_root_response_event(event) {
120 let _ = self.event_sender.send(event);
121 }
122 }
123
124 pub async fn abandon(self) {
125 self.execution_reservation.abandon().await;
126 }
127}
128
129pub async fn reserve_response_resume_handoff(
133 port: &dyn ResumeExecutionPort,
134 session_id: &str,
135 timeout: std::time::Duration,
136) -> Result<ResponseResumeHandoff, ResumeOutcome> {
137 let event_sender = port.get_or_create_event_sender(session_id).await;
138 let deadline = tokio::time::Instant::now() + timeout;
139 loop {
140 match port
141 .reserve_session_execution(session_id, &event_sender)
142 .await
143 {
144 SessionExecutionReserveOutcome::Reserved(mut execution_reservation) => {
145 if let Err(error) = port
146 .prepare_response_execution(&mut execution_reservation)
147 .await
148 {
149 let run_id = execution_reservation.run_id().to_string();
150 tracing::debug!(%session_id, %error, "response successor is not ready; question retained");
151 execution_reservation.abandon().await;
152 if tokio::time::Instant::now() >= deadline {
153 return Err(ResumeOutcome::AlreadyRunning { run_id });
154 }
155 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
156 continue;
157 }
158 return Ok(ResponseResumeHandoff {
159 execution_reservation,
160 event_sender,
161 });
162 }
163 SessionExecutionReserveOutcome::AlreadyRunning { run_id } => {
164 if tokio::time::Instant::now() >= deadline {
165 return Err(ResumeOutcome::AlreadyRunning { run_id });
166 }
167 }
168 }
169 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
170 }
171}
172
173pub async fn resume_session_execution_with_handoff(
176 port: &dyn ResumeExecutionPort,
177 session_id: &str,
178 session: Session,
179 config: ResumeConfigSnapshot,
180 handoff: ResponseResumeHandoff,
181) -> ResumeOutcome {
182 if !has_pending_user_message(&session) {
183 tokio::spawn(async move {
184 handoff.abandon().await;
185 });
186 return ResumeOutcome::Completed;
187 }
188
189 let ResponseResumeHandoff {
190 execution_reservation,
191 event_sender,
192 } = handoff;
193 let run_id = execution_reservation.run_id().to_string();
194 let request = ResumeSpawnRequest {
195 session_id: session_id.to_string(),
196 session,
197 execution_reservation,
198 event_sender,
199 config,
200 };
201 if let Err(request) = port.dispatch_resume_execution(request) {
202 port.spawn_resume_execution(request).await;
203 }
204 ResumeOutcome::Started { run_id }
205}
206
207pub async fn resume_session_execution(
219 port: &dyn ResumeExecutionPort,
220 session_id: &str,
221 config: ResumeConfigSnapshot,
222) -> ResumeOutcome {
223 let Some(session) = port.load_session(session_id).await else {
225 return ResumeOutcome::NotFound;
226 };
227
228 if !has_pending_user_message(&session) {
229 return ResumeOutcome::Completed;
230 }
231
232 let event_sender = port.get_or_create_event_sender(session_id).await;
234 let reservation = match port
235 .reserve_session_execution(session_id, &event_sender)
236 .await
237 {
238 SessionExecutionReserveOutcome::Reserved(reservation) => reservation,
239 SessionExecutionReserveOutcome::AlreadyRunning { run_id } => {
240 return ResumeOutcome::AlreadyRunning { run_id };
241 }
242 };
243 let run_id = reservation.run_id().to_string();
244
245 port.spawn_resume_execution(ResumeSpawnRequest {
246 session_id: session_id.to_string(),
247 session,
248 execution_reservation: reservation,
249 event_sender,
250 config,
251 })
252 .await;
253
254 ResumeOutcome::Started { run_id }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use crate::execution::{reserve_runner_core, AgentRunner, ReserveOutcome};
261 use bamboo_agent_core::Message;
262 use std::collections::{BTreeSet, HashMap};
263 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
264 use std::sync::Arc;
265 use tokio::sync::{Mutex, Notify, RwLock};
266
267 struct CancellingResumePort {
268 durable: Mutex<Session>,
269 runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
270 senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
271 spawn_entered: Arc<Notify>,
272 detached_release: Arc<Notify>,
273 detached_finished: Arc<AtomicBool>,
274 saw_marker_at_adapter: Arc<AtomicBool>,
275 save_calls: AtomicUsize,
276 }
277
278 #[async_trait]
279 impl ResumeExecutionPort for CancellingResumePort {
280 async fn load_session(&self, _session_id: &str) -> Option<Session> {
281 Some(self.durable.lock().await.clone())
282 }
283
284 async fn save_and_cache_session(&self, session: &mut Session) {
285 self.save_calls.fetch_add(1, Ordering::SeqCst);
286 *self.durable.lock().await = session.clone();
287 }
288
289 async fn reserve_session_execution(
290 &self,
291 session_id: &str,
292 event_sender: &broadcast::Sender<AgentEvent>,
293 ) -> SessionExecutionReserveOutcome {
294 match reserve_runner_core(&self.runners, &self.senders, session_id, event_sender).await
295 {
296 ReserveOutcome::Reserved(reservation) => SessionExecutionReserveOutcome::Reserved(
297 SessionExecutionReservation::from_pending_registration(
298 session_id,
299 reservation,
300 None,
301 self.runners.clone(),
302 ),
303 ),
304 ReserveOutcome::AlreadyRunning(run_id) => {
305 SessionExecutionReserveOutcome::AlreadyRunning { run_id }
306 }
307 }
308 }
309
310 async fn get_or_create_event_sender(
311 &self,
312 session_id: &str,
313 ) -> broadcast::Sender<AgentEvent> {
314 if let Some(sender) = self.senders.read().await.get(session_id).cloned() {
315 return sender;
316 }
317 let sender = broadcast::channel(16).0;
318 self.senders
319 .write()
320 .await
321 .insert(session_id.to_string(), sender.clone());
322 sender
323 }
324
325 async fn spawn_resume_execution(&self, request: ResumeSpawnRequest) {
326 self.saw_marker_at_adapter.store(
327 request
328 .session
329 .metadata
330 .contains_key("execute.startup_handoff_at"),
331 Ordering::SeqCst,
332 );
333 self.spawn_entered.notify_one();
334 std::future::pending::<()>().await;
335 }
336
337 fn dispatch_resume_execution(
338 &self,
339 request: ResumeSpawnRequest,
340 ) -> Result<(), ResumeSpawnRequest> {
341 self.saw_marker_at_adapter.store(
342 request
343 .session
344 .metadata
345 .contains_key("execute.startup_handoff_at"),
346 Ordering::SeqCst,
347 );
348 let spawn_entered = self.spawn_entered.clone();
349 let detached_release = self.detached_release.clone();
350 let detached_finished = self.detached_finished.clone();
351 tokio::spawn(async move {
352 let mut reservation = request.execution_reservation;
353 reservation
354 .ensure_registered()
355 .await
356 .expect("detached owner registers exact successor");
357 spawn_entered.notify_one();
358 detached_release.notified().await;
359 detached_finished.store(true, Ordering::SeqCst);
360 });
361 Ok(())
362 }
363 }
364
365 fn test_resume_config() -> ResumeConfigSnapshot {
366 ResumeConfigSnapshot {
367 provider_name: "test".to_string(),
368 provider_type: None,
369 fast_model: None,
370 fast_model_ref: None,
371 background_model: None,
372 background_model_ref: None,
373 background_model_provider: None,
374 summarization_model: None,
375 summarization_model_ref: None,
376 summarization_model_provider: None,
377 disabled_tools: BTreeSet::new(),
378 disabled_skill_ids: BTreeSet::new(),
379 image_fallback: None,
380 gold_config: None,
381 }
382 }
383
384 #[tokio::test]
385 async fn caller_cancellation_after_dispatch_keeps_detached_resume_owner() {
386 let mut session = Session::new("resume-cancel", "model");
387 session.add_message(Message::tool_result("call-1", "Selected response: A"));
388 session.metadata.insert(
389 "clarification_resume_pending".to_string(),
390 "true".to_string(),
391 );
392 session.metadata.insert(
393 "execute.startup_handoff_at".to_string(),
394 "2026-08-10T00:00:00.000Z".to_string(),
395 );
396 let port = Arc::new(CancellingResumePort {
397 durable: Mutex::new(session),
398 runners: Arc::new(RwLock::new(HashMap::new())),
399 senders: Arc::new(RwLock::new(HashMap::new())),
400 spawn_entered: Arc::new(Notify::new()),
401 detached_release: Arc::new(Notify::new()),
402 detached_finished: Arc::new(AtomicBool::new(false)),
403 saw_marker_at_adapter: Arc::new(AtomicBool::new(false)),
404 save_calls: AtomicUsize::new(0),
405 });
406
407 let handoff = reserve_response_resume_handoff(
408 port.as_ref(),
409 "resume-cancel",
410 std::time::Duration::from_secs(1),
411 )
412 .await
413 .expect("reserve exact response successor");
414 let committed_session = port.durable.lock().await.clone();
415 let task_port = port.clone();
416 let resume = tokio::spawn(async move {
417 let outcome = resume_session_execution_with_handoff(
418 task_port.as_ref(),
419 "resume-cancel",
420 committed_session,
421 test_resume_config(),
422 handoff,
423 )
424 .await;
425 assert!(matches!(outcome, ResumeOutcome::Started { .. }));
426 std::future::pending::<()>().await
427 });
428 tokio::time::timeout(
429 std::time::Duration::from_secs(5),
430 port.spawn_entered.notified(),
431 )
432 .await
433 .expect("detached resume owner must register the successor");
434 resume.abort();
435 let _ = resume.await;
436 port.detached_release.notify_one();
437 tokio::time::timeout(std::time::Duration::from_secs(5), async {
438 while !port.detached_finished.load(Ordering::SeqCst) {
439 tokio::task::yield_now().await;
440 }
441 })
442 .await
443 .expect("detached owner must survive caller cancellation");
444
445 assert!(port.saw_marker_at_adapter.load(Ordering::SeqCst));
446 assert_eq!(port.save_calls.load(Ordering::SeqCst), 0);
447 let durable = port.durable.lock().await;
448 assert_eq!(
449 durable
450 .metadata
451 .get("clarification_resume_pending")
452 .map(String::as_str),
453 Some("true")
454 );
455 assert!(durable.metadata.contains_key("execute.startup_handoff_at"));
456 }
457}