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