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    /// Get or create the long-lived broadcast sender for session events.
45    async fn get_or_create_event_sender(&self, session_id: &str) -> broadcast::Sender<AgentEvent>;
46
47    /// Spawn the resume execution loop in the background.
48    ///
49    /// The adapter creates the mpsc channel, spawns the event forwarder,
50    /// and calls the server's agent execution spawner.
51    async fn spawn_resume_execution(&self, request: ResumeSpawnRequest);
52
53    /// Transfer a prepared resume request to an execution owner whose lifetime
54    /// is independent of the caller. Implementations that support response
55    /// handoffs return `Ok(())` only after the request (including its exact
56    /// runner reservation) has moved into a detached task.
57    ///
58    /// The default preserves source compatibility for external port adapters;
59    /// they retain the original awaited `spawn_resume_execution` behavior until
60    /// they opt into cancellation-safe dispatch.
61    #[allow(clippy::result_large_err)]
62    fn dispatch_resume_execution(
63        &self,
64        request: ResumeSpawnRequest,
65    ) -> Result<(), ResumeSpawnRequest> {
66        Err(request)
67    }
68}
69
70// ---------------------------------------------------------------------------
71// Value types
72// ---------------------------------------------------------------------------
73
74/// Request captured for the adapter to spawn a resumed agent execution.
75///
76/// This bundles everything the server-side spawner needs, keeping the
77/// application layer free of `AppState` and server-specific types.
78pub 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
86/// Runner ownership reserved before consuming a pending clarification. This
87/// closes the idle-check -> response-CAS -> resume-reserve gap: no competing
88/// entrypoint can take the successor slot after the answer is durably cleared.
89pub 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
108/// Atomically acquire the successor runner slot before a response transaction.
109/// An existing suspended owner is allowed to finish; timeout returns its last
110/// run id without consuming the pending question.
111pub 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
139/// Start a resumed run using ownership acquired before the pending response
140/// was consumed. The supplied session is the exact durable CAS result.
141pub 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
173// ---------------------------------------------------------------------------
174// Use case
175// ---------------------------------------------------------------------------
176
177/// Resume agent execution on an existing session.
178///
179/// Returns the outcome of the resume attempt:
180/// - `Started` — execution spawned successfully
181/// - `AlreadyRunning` — a runner is already active
182/// - `Completed` — no pending user message
183/// - `NotFound` — session not found
184pub async fn resume_session_execution(
185    port: &dyn ResumeExecutionPort,
186    session_id: &str,
187    config: ResumeConfigSnapshot,
188) -> ResumeOutcome {
189    // Load session.
190    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    // Reserve runner slot.
199    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}