Skip to main content

bamboo_engine/session_app/
resume.rs

1//! Resume execution use case.
2//!
3//! Provides the application-layer logic for resuming agent execution on an
4//! existing session (e.g. after a user responds to a pending question).
5//! The server layer implements `ResumeExecutionPort` to supply the
6//! infrastructure operations.
7
8use 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// ---------------------------------------------------------------------------
18// Port trait
19// ---------------------------------------------------------------------------
20
21/// Adapter trait for resume execution infrastructure.
22///
23/// Implementations bridge the use case to server-specific concerns
24/// (storage, runner lifecycle, agent spawning).
25#[async_trait]
26pub trait ResumeExecutionPort: Send + Sync {
27    /// Load a session by ID. Returns `None` if not found.
28    async fn load_session(&self, session_id: &str) -> Option<Session>;
29
30    /// Persist a session and update any caches.
31    ///
32    /// Implementations may merge concurrent UI edits to
33    /// title/title_generated/pinned/title_version
34    /// from disk back into `session` (which is why this takes `&mut`).
35    async fn save_and_cache_session(&self, session: &mut Session);
36
37    /// Reserve the shared runner/router ownership for the given session.
38    async fn reserve_session_execution(
39        &self,
40        session_id: &str,
41        event_sender: &broadcast::Sender<AgentEvent>,
42    ) -> SessionExecutionReserveOutcome;
43
44    /// Prepare the exact reserved successor before a pending-response CAS.
45    /// Hosts with ordinary Root ownership bind its fenced writer here.
46    async fn prepare_response_execution(
47        &self,
48        _reservation: &mut SessionExecutionReservation,
49    ) -> std::io::Result<()> {
50        Ok(())
51    }
52
53    /// Get or create the long-lived broadcast sender for session events.
54    async fn get_or_create_event_sender(&self, session_id: &str) -> broadcast::Sender<AgentEvent>;
55
56    /// Spawn the resume execution loop in the background.
57    ///
58    /// The adapter creates the mpsc channel, spawns the event forwarder,
59    /// and calls the server's agent execution spawner.
60    async fn spawn_resume_execution(&self, request: ResumeSpawnRequest);
61
62    /// Host-only opt-in for a fallback resolved from its config snapshot.
63    /// Existing external adapters retain their original spawn behavior.
64    #[doc(hidden)]
65    async fn spawn_configured_resume_execution(&self, request: ResumeSpawnRequest) {
66        self.spawn_resume_execution(request).await;
67    }
68
69    /// Preserve an adapter's existing detached-handoff behavior by default.
70    #[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    /// Transfer a prepared resume request to an execution owner whose lifetime
80    /// is independent of the caller. Implementations that support response
81    /// handoffs return `Ok(())` only after the request (including its exact
82    /// runner reservation) has moved into a detached task.
83    ///
84    /// The default preserves source compatibility for external port adapters;
85    /// they retain the original awaited `spawn_resume_execution` behavior until
86    /// they opt into cancellation-safe dispatch.
87    #[allow(clippy::result_large_err)]
88    fn dispatch_resume_execution(
89        &self,
90        request: ResumeSpawnRequest,
91    ) -> Result<(), ResumeSpawnRequest> {
92        Err(request)
93    }
94}
95
96// ---------------------------------------------------------------------------
97// Value types
98// ---------------------------------------------------------------------------
99
100/// Request captured for the adapter to spawn a resumed agent execution.
101///
102/// This bundles everything the server-side spawner needs, keeping the
103/// application layer free of `AppState` and server-specific types.
104pub 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
112/// Runner ownership reserved before consuming a pending clarification. This
113/// closes the idle-check -> response-CAS -> resume-reserve gap: no competing
114/// entrypoint can take the successor slot after the answer is durably cleared.
115pub 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    /// The private fenced persistence captured by this exact successor.
126    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    /// Let a terminal response use the same fenced forwarder without resuming
137    /// the model. Its owner must publish the history barrier before release.
138    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
153/// Atomically acquire the successor runner slot before a response transaction.
154/// An existing suspended owner is allowed to finish; timeout returns its last
155/// run id without consuming the pending question.
156pub 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
197/// Start a resumed run using ownership acquired before the pending response
198/// was consumed. The supplied session is the exact durable CAS result.
199pub 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/// Host-generated config snapshots carry their origin separately from values.
210#[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
263// ---------------------------------------------------------------------------
264// Use case
265// ---------------------------------------------------------------------------
266
267/// Resume agent execution on an existing session.
268///
269/// Returns the outcome of the resume attempt:
270/// - `Started` — execution spawned successfully
271/// - `AlreadyRunning` — a runner is already active
272/// - `Completed` — no pending user message
273/// - `NotFound` — session not found
274pub 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/// Host-generated config snapshots carry their origin separately from values.
283#[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    // Load session.
299    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    // Reserve runner slot.
308    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}