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    /// Transfer a prepared resume request to an execution owner whose lifetime
63    /// is independent of the caller. Implementations that support response
64    /// handoffs return `Ok(())` only after the request (including its exact
65    /// runner reservation) has moved into a detached task.
66    ///
67    /// The default preserves source compatibility for external port adapters;
68    /// they retain the original awaited `spawn_resume_execution` behavior until
69    /// they opt into cancellation-safe dispatch.
70    #[allow(clippy::result_large_err)]
71    fn dispatch_resume_execution(
72        &self,
73        request: ResumeSpawnRequest,
74    ) -> Result<(), ResumeSpawnRequest> {
75        Err(request)
76    }
77}
78
79// ---------------------------------------------------------------------------
80// Value types
81// ---------------------------------------------------------------------------
82
83/// Request captured for the adapter to spawn a resumed agent execution.
84///
85/// This bundles everything the server-side spawner needs, keeping the
86/// application layer free of `AppState` and server-specific types.
87pub 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
95/// Runner ownership reserved before consuming a pending clarification. This
96/// closes the idle-check -> response-CAS -> resume-reserve gap: no competing
97/// entrypoint can take the successor slot after the answer is durably cleared.
98pub 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    /// Let a terminal response use the same fenced forwarder without resuming
113    /// the model. Its owner must publish the history barrier before release.
114    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
129/// Atomically acquire the successor runner slot before a response transaction.
130/// An existing suspended owner is allowed to finish; timeout returns its last
131/// run id without consuming the pending question.
132pub 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
173/// Start a resumed run using ownership acquired before the pending response
174/// was consumed. The supplied session is the exact durable CAS result.
175pub 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
207// ---------------------------------------------------------------------------
208// Use case
209// ---------------------------------------------------------------------------
210
211/// Resume agent execution on an existing session.
212///
213/// Returns the outcome of the resume attempt:
214/// - `Started` — execution spawned successfully
215/// - `AlreadyRunning` — a runner is already active
216/// - `Completed` — no pending user message
217/// - `NotFound` — session not found
218pub async fn resume_session_execution(
219    port: &dyn ResumeExecutionPort,
220    session_id: &str,
221    config: ResumeConfigSnapshot,
222) -> ResumeOutcome {
223    // Load session.
224    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    // Reserve runner slot.
233    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}