Skip to main content

bamboo_engine/runtime/execution/
runner_lifecycle.rs

1//! Runner lifecycle helpers for background agent execution.
2//!
3//! Provides shared implementations for:
4//! - Runner reservation (check existing → create new with cancel token)
5//! - Runner finalization (map execution result to `AgentStatus`)
6//! - Status mapping
7
8use std::collections::HashMap;
9use std::sync::Arc;
10
11use chrono::Utc;
12use tokio::sync::{broadcast, RwLock};
13use tokio_util::sync::CancellationToken;
14
15use bamboo_agent_core::{AgentError, AgentEvent};
16
17use super::runner_state::{AgentRunner, AgentStatus};
18
19/// Reservation result from [`reserve_runner_core`] / [`try_reserve_runner`].
20#[derive(Debug, Clone)]
21pub struct RunnerReservation {
22    pub cancel_token: CancellationToken,
23    pub run_id: String,
24    pub(super) physical_admission: Option<super::root_execution_lifetime::RootRunAdmission>,
25}
26
27/// Outcome of the shared reservation core.
28#[derive(Debug, Clone)]
29pub enum ReserveOutcome {
30    /// A fresh runner was reserved (any stale runner was replaced).
31    Reserved(RunnerReservation),
32    /// A `Running` runner already exists for this session; carries its `run_id`
33    /// so the caller can correlate subsequent SSE/WS events.
34    AlreadyRunning(String),
35}
36
37/// Shared runner-reservation core used by BOTH the server's `reserve_runner`
38/// and the engine's [`try_reserve_runner`], so the idle-eviction sender
39/// re-assert can never drift between the two paths again (#346).
40///
41/// While holding the `runners` write lock it:
42/// 1. short-circuits with [`ReserveOutcome::AlreadyRunning`] if a `Running`
43///    runner already exists;
44/// 2. otherwise replaces any stale runner with a fresh `Running` one whose
45///    `event_sender` is `event_sender`;
46/// 3. **re-asserts `event_sender` into `senders`** (`entry().or_insert_with`),
47///    STILL holding the `runners` write lock.
48///
49/// Step 3 closes the idle-sweep TOCTOU: a caller obtains the sender via
50/// `get_or_create_event_sender` and then reserves, but the 60s idle sweep can
51/// evict that session's sender in between (it drops a terminal runner and its
52/// sender together under the same `runners` ⊃ `senders` lock order). Without the
53/// re-assert, a resumed / re-executed run would publish to a channel no longer
54/// registered in `senders`, and a later subscriber's `get_or_create_event_sender`
55/// would mint a *different* channel and silently miss every event of that run.
56/// Because both this re-assert and the sweep acquire `runners` first, they are
57/// mutually exclusive, and a freshly-inserted `Running` runner is never swept —
58/// so the re-asserted sender is durable for the run.
59pub async fn reserve_runner_core(
60    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
61    senders: &Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
62    session_id: &str,
63    event_sender: &broadcast::Sender<AgentEvent>,
64) -> ReserveOutcome {
65    reserve_runner_inner(runners, senders, session_id, event_sender, None).await
66}
67
68/// The logical owner chooses this identity before replacing retained output.
69/// Router-native activation still uses its existing raw, two-phase primitive.
70pub(super) async fn reserve_runner_for_registration(
71    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
72    senders: &Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
73    session_id: &str,
74    event_sender: &broadcast::Sender<AgentEvent>,
75    identity: RunnerReservation,
76) -> ReserveOutcome {
77    reserve_runner_inner(runners, senders, session_id, event_sender, Some(identity)).await
78}
79
80async fn reserve_runner_inner(
81    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
82    senders: &Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
83    session_id: &str,
84    event_sender: &broadcast::Sender<AgentEvent>,
85    identity: Option<RunnerReservation>,
86) -> ReserveOutcome {
87    let mut runners_guard = runners.write().await;
88    if let Some(runner) = runners_guard.get(session_id) {
89        if matches!(runner.status, AgentStatus::Running) {
90            return ReserveOutcome::AlreadyRunning(runner.run_id.clone());
91        }
92    }
93
94    // Acquire every fallible/cancellation point before mutating the runner
95    // registry. In particular, cancellation while waiting for the sender map
96    // must not leave a `Running` slot with no task behind it.
97    let mut senders_guard = senders.write().await;
98    let mut runner = AgentRunner::new();
99    if let Some(identity) = identity {
100        runner.run_id = identity.run_id;
101        runner.cancel_token = identity.cancel_token;
102    }
103    if let Some(previous) = runners_guard.get(session_id) {
104        runner.root_execution = previous.root_execution.clone();
105    }
106    // Serialize the last admission decision with an exact reset reservation.
107    // No Storage or target publication mutex is acquired here. This existing
108    // busy result cannot execute a new run, and is not a physical-idle claim.
109    let physical_admission = match runner.root_execution.admit(&runner.run_id) {
110        Ok(admission) => admission,
111        Err(_) => {
112            return ReserveOutcome::AlreadyRunning(
113                runners_guard
114                    .get(session_id)
115                    .map_or_else(|| runner.run_id.clone(), |previous| previous.run_id.clone()),
116            )
117        }
118    };
119    if let Some(previous) = runners_guard.get(session_id) {
120        previous.event_publication.retire().await;
121    }
122    runner.transfer_root_slot(runners_guard.get_mut(session_id));
123    runners_guard.remove(session_id);
124
125    runner.status = AgentStatus::Running;
126    runner.event_sender = event_sender.clone();
127    let reservation = RunnerReservation {
128        cancel_token: runner.cancel_token.clone(),
129        run_id: runner.run_id.clone(),
130        physical_admission: Some(physical_admission),
131    };
132    runners_guard.insert(session_id.to_string(), runner);
133
134    // (3) Re-assert the session sender under the held `runners` write lock. Same
135    // channel as `event_sender`, so a no-op in the common case; restores the map
136    // entry if the idle sweep removed it. Lock order (runners ⊃ senders) matches
137    // the sweep, so this cannot deadlock.
138    senders_guard
139        .entry(session_id.to_string())
140        .or_insert_with(|| event_sender.clone());
141
142    ReserveOutcome::Reserved(reservation)
143}
144
145/// Try to reserve a runner for the given session.
146///
147/// Returns `None` if a `Running` runner already exists (the caller skips
148/// execution and surfaces the existing `run_id` separately). Otherwise reserves
149/// a fresh runner AND re-asserts the session sender into `senders` — see
150/// [`reserve_runner_core`].
151///
152/// The re-assert is **required, not optional**: this function is reached by the
153/// resume paths (`/respond`, child-completion coordinator, gold auto-answer via
154/// `resume_session_execution`), which re-execute a long-lived session under the
155/// SAME id after a wait that easily exceeds the idle TTL — exactly the
156/// evict-then-re-execute race #346's sweep newly opens.
157pub async fn try_reserve_runner(
158    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
159    senders: &Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
160    session_id: &str,
161    event_sender: &broadcast::Sender<AgentEvent>,
162) -> Option<RunnerReservation> {
163    match reserve_runner_core(runners, senders, session_id, event_sender).await {
164        ReserveOutcome::Reserved(reservation) => Some(reservation),
165        ReserveOutcome::AlreadyRunning(_) => {
166            tracing::debug!("[{}] Runner already running, skipping", session_id);
167            None
168        }
169    }
170}
171
172/// Drain publication before removing the old generation from the registry.
173/// If the caller is cancelled at the await, the closed old fence remains in
174/// the map, so a subsequent reservation still has to drain it before Started.
175pub async fn remove_runner_entry(
176    runners: &mut HashMap<String, AgentRunner>,
177    session_id: &str,
178) -> Option<AgentRunner> {
179    if let Some(runner) = runners.get(session_id) {
180        if runner.root_reset_admission_reserved() {
181            return None;
182        }
183        // Arm execution cancellation before the drain can suspend. Dropping a
184        // delete/rollback future must not leave a live runner with a closed
185        // event fence that a subsequent execute mistakes for healthy work.
186        runner.cancel_token.cancel();
187        runner.event_publication.retire().await;
188    }
189    runners.remove(session_id)
190}
191
192/// Map an execution result to `AgentStatus`.
193pub fn status_from_execution_result(result: &Result<(), AgentError>) -> AgentStatus {
194    match result {
195        Ok(_) => AgentStatus::Completed,
196        Err(error) if error.is_cancelled() => AgentStatus::Cancelled,
197        Err(error) => AgentStatus::Error(error.to_string()),
198    }
199}
200
201/// Update a runner's terminal status and completion timestamp.
202pub async fn finalize_runner(
203    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
204    session_id: &str,
205    result: &Result<(), AgentError>,
206) {
207    let mut guard = runners.write().await;
208    if let Some(runner) = guard.get_mut(session_id) {
209        runner.status = status_from_execution_result(result);
210        runner.completed_at = Some(Utc::now());
211    }
212}
213
214/// Finalize only the exact reserved runner.
215///
216/// Registration collisions can expose a stale caller after another entry
217/// point has become authoritative. That caller must release its own slot
218/// without ever terminalizing a newer runner stored under the same session id.
219pub async fn finalize_runner_exact(
220    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
221    session_id: &str,
222    run_id: &str,
223    result: &Result<(), AgentError>,
224) -> bool {
225    let mut guard = runners.write().await;
226    let Some(runner) = guard.get_mut(session_id) else {
227        return false;
228    };
229    if runner.run_id != run_id {
230        return false;
231    }
232    runner.status = status_from_execution_result(result);
233    runner.completed_at = Some(Utc::now());
234    true
235}
236
237/// Finalize a rejected caller only when it owns a distinct runner slot.
238///
239/// A duplicate task can race with the live task while both observe the same
240/// registry run id. Rejecting that duplicate must not terminalize the slot that
241/// still belongs to the registered owner.
242pub async fn finalize_rejected_runner_if_distinct(
243    runners: &Arc<RwLock<HashMap<String, AgentRunner>>>,
244    session_id: &str,
245    existing_owner_run_id: &str,
246    attempted_run_id: &str,
247    result: &Result<(), AgentError>,
248) -> bool {
249    if existing_owner_run_id == attempted_run_id {
250        return false;
251    }
252    finalize_runner_exact(runners, session_id, attempted_run_id, result).await
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[tokio::test]
260    async fn cancelling_removal_keeps_the_predecessor_fence_discoverable() {
261        use std::sync::Barrier;
262        let mut runner = AgentRunner::new();
263        runner.status = AgentStatus::Running;
264        let cancel = runner.cancel_token.clone();
265        let publication = runner.event_publication.clone();
266        let entered = Arc::new(Barrier::new(2));
267        let release = Arc::new(Barrier::new(2));
268        let publisher = {
269            let publication = publication.clone();
270            let entered = entered.clone();
271            let release = release.clone();
272            std::thread::spawn(move || {
273                publication.publish(|| {
274                    entered.wait();
275                    release.wait();
276                })
277            })
278        };
279        entered.wait();
280        let mut runners = HashMap::from([("child".to_string(), runner)]);
281        let mut removing = Box::pin(remove_runner_entry(&mut runners, "child"));
282        assert!(futures::poll!(removing.as_mut()).is_pending());
283        drop(removing);
284        assert!(
285            cancel.is_cancelled(),
286            "retired execution must be told to stop even when removal is cancelled"
287        );
288        assert!(
289            runners.contains_key("child"),
290            "a successor must still find the old fence after cancellation"
291        );
292        assert!(!publication.publish(|| panic!("retired run must reject late frames")));
293        release.wait();
294        publisher.join().unwrap();
295        assert!(remove_runner_entry(&mut runners, "child").await.is_some());
296        assert!(runners.is_empty());
297    }
298
299    fn new_runners() -> Arc<RwLock<HashMap<String, AgentRunner>>> {
300        Arc::new(RwLock::new(HashMap::new()))
301    }
302
303    fn new_senders() -> Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>> {
304        Arc::new(RwLock::new(HashMap::new()))
305    }
306
307    fn new_broadcaster() -> broadcast::Sender<AgentEvent> {
308        broadcast::channel(100).0
309    }
310
311    #[tokio::test]
312    async fn try_reserve_runner_creates_runner_with_running_status() {
313        let runners = new_runners();
314        let senders = new_senders();
315        let tx = new_broadcaster();
316        let token = try_reserve_runner(&runners, &senders, "s1", &tx).await;
317        assert!(token.is_some());
318
319        let guard = runners.read().await;
320        let runner = guard.get("s1").unwrap();
321        assert!(matches!(runner.status, AgentStatus::Running));
322    }
323
324    #[tokio::test]
325    async fn try_reserve_runner_returns_none_when_already_running() {
326        let runners = new_runners();
327        let senders = new_senders();
328        let tx = new_broadcaster();
329        let _ = try_reserve_runner(&runners, &senders, "s1", &tx).await;
330        let second = try_reserve_runner(&runners, &senders, "s1", &tx).await;
331        assert!(second.is_none());
332    }
333
334    #[tokio::test]
335    async fn try_reserve_runner_replaces_completed_runner() {
336        let runners = new_runners();
337        let senders = new_senders();
338        let tx = new_broadcaster();
339        let _ = try_reserve_runner(&runners, &senders, "s1", &tx).await;
340
341        {
342            let mut guard = runners.write().await;
343            let runner = guard.get_mut("s1").unwrap();
344            runner.status = AgentStatus::Completed;
345        }
346
347        let second = try_reserve_runner(&runners, &senders, "s1", &tx).await;
348        assert!(second.is_some());
349    }
350
351    #[tokio::test]
352    async fn rejected_duplicate_does_not_terminalize_shared_owner_slot() {
353        let runners = new_runners();
354        let senders = new_senders();
355        let tx = new_broadcaster();
356        let reservation = try_reserve_runner(&runners, &senders, "s1", &tx)
357            .await
358            .unwrap();
359        let rejected = Err(AgentError::Cancelled);
360
361        assert!(
362            !finalize_rejected_runner_if_distinct(
363                &runners,
364                "s1",
365                &reservation.run_id,
366                &reservation.run_id,
367                &rejected,
368            )
369            .await
370        );
371        assert!(matches!(
372            runners.read().await.get("s1").map(|runner| &runner.status),
373            Some(AgentStatus::Running)
374        ));
375    }
376
377    #[tokio::test]
378    async fn rejected_distinct_runner_releases_only_its_exact_slot() {
379        let runners = new_runners();
380        let senders = new_senders();
381        let tx = new_broadcaster();
382        let reservation = try_reserve_runner(&runners, &senders, "s1", &tx)
383            .await
384            .unwrap();
385        let rejected = Err(AgentError::Cancelled);
386
387        assert!(
388            finalize_rejected_runner_if_distinct(
389                &runners,
390                "s1",
391                "different-live-owner",
392                &reservation.run_id,
393                &rejected,
394            )
395            .await
396        );
397        assert!(matches!(
398            runners.read().await.get("s1").map(|runner| &runner.status),
399            Some(AgentStatus::Cancelled)
400        ));
401    }
402
403    #[tokio::test]
404    async fn try_reserve_runner_reasserts_evicted_sender_so_late_subscriber_receives() {
405        // Regression for #346: the resume path (`resume_session_execution`)
406        // obtains the session sender, then reserves — and the idle sweep can
407        // evict that sender in between. `try_reserve_runner` must re-assert it so
408        // the resumed run's channel stays registered and a late subscriber lands
409        // on the SAME channel the run publishes to.
410        use super::super::session_events::get_or_create_event_sender;
411        use bamboo_agent_core::AgentEvent;
412
413        let runners = new_runners();
414        let senders = new_senders();
415
416        // Resume ordering: obtain sender (inserts into the map) ...
417        let session_tx = get_or_create_event_sender(&senders, "s1").await;
418        // ... then the idle sweep evicts this session's sender before reservation.
419        senders.write().await.remove("s1");
420        assert!(senders.read().await.get("s1").is_none());
421
422        // Reserve with the clone obtained before the eviction.
423        let reservation = try_reserve_runner(&runners, &senders, "s1", &session_tx).await;
424        assert!(reservation.is_some(), "reservation must succeed");
425
426        // The sender MUST be re-asserted into the map. Without the fix it is
427        // absent here and the run's channel is orphaned.
428        assert!(
429            senders.read().await.get("s1").is_some(),
430            "reservation must re-assert the evicted session sender into the map"
431        );
432
433        // A late subscriber (SSE/WS handler: get_or_create then subscribe) must
434        // land on the SAME channel the run publishes to.
435        let subscriber_tx = get_or_create_event_sender(&senders, "s1").await;
436        let mut rx = subscriber_tx.subscribe();
437
438        // The resumed run publishes via its reserved channel (== session_tx).
439        let _ = session_tx.send(AgentEvent::SessionDeleted {
440            session_id: "s1".to_string(),
441        });
442
443        let received = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await;
444        assert!(
445            matches!(received, Ok(Ok(_))),
446            "late subscriber must receive events from the resumed run; without the \
447             re-assert, get_or_create mints a fresh channel and the event is lost"
448        );
449    }
450
451    #[tokio::test]
452    async fn cancellation_before_atomic_registry_commit_leaves_no_zombie_runner() {
453        let runners = new_runners();
454        let senders = new_senders();
455        let tx = new_broadcaster();
456        let held_senders = senders.write().await;
457
458        let task = {
459            let runners = runners.clone();
460            let senders = senders.clone();
461            let tx = tx.clone();
462            tokio::spawn(
463                async move { reserve_runner_core(&runners, &senders, "cancelled", &tx).await },
464            )
465        };
466
467        // Wait until the reservation owns the runners lock and is blocked on
468        // the sender lock. No registry mutation is allowed before that final
469        // cancellation point completes.
470        for _ in 0..100 {
471            if runners.try_write().is_err() {
472                break;
473            }
474            tokio::task::yield_now().await;
475        }
476        assert!(
477            runners.try_write().is_err(),
478            "reservation never reached the sender-lock barrier"
479        );
480        task.abort();
481        assert!(task.await.unwrap_err().is_cancelled());
482        drop(held_senders);
483
484        assert!(
485            runners.read().await.get("cancelled").is_none(),
486            "a cancelled reservation must not leave a Running slot without a task"
487        );
488        assert!(matches!(
489            reserve_runner_core(&runners, &senders, "cancelled", &tx).await,
490            ReserveOutcome::Reserved(_)
491        ));
492    }
493
494    #[test]
495    fn status_from_execution_result_maps_correctly() {
496        let ok_result: Result<(), AgentError> = Ok(());
497        assert!(matches!(
498            status_from_execution_result(&ok_result),
499            AgentStatus::Completed
500        ));
501
502        // Cancellation is detected by matching the `AgentError::Cancelled`
503        // variant, not by substring-matching the (display) message — note the
504        // variant's message is "Cancelled", which would not even contain the
505        // lowercase "cancelled" the old code searched for.
506        let cancelled: Result<(), AgentError> = Err(AgentError::Cancelled);
507        assert!(matches!(
508            status_from_execution_result(&cancelled),
509            AgentStatus::Cancelled
510        ));
511
512        let failed: Result<(), AgentError> = Err(AgentError::LLM("network error".to_string()));
513        match status_from_execution_result(&failed) {
514            AgentStatus::Error(message) => assert!(message.contains("network error")),
515            other => panic!("unexpected status: {other:?}"),
516        }
517    }
518}