Skip to main content

behest_runtime/
subscription.rs

1//! Coordination layer combining replay and live fanout.
2//!
3//! [`RuntimeSubscriptionHub`] stitches [`RuntimeEventStore`] (authoritative
4//! replay) together with [`RuntimeStreamAdapter`] (best-effort live fanout) so
5//! a reconnecting client can receive `replay` first and then drain `live`.
6//!
7//! [`RuntimeEventBridge`] drains [`AgentRuntime::subscribe`](super::agent::AgentRuntime::subscribe)
8//! into the store+adapter pair: every event is appended first, and only on
9//! success is it published to the run (and, when known, session) room.
10//! Per the at-least-once contract, consumers deduplicate via
11//! [`RuntimeEventEnvelope::event_id`](super::stream::RuntimeEventEnvelope::event_id)
12//! or `seq`.
13
14use std::sync::Arc;
15
16use thiserror::Error;
17use tokio::sync::broadcast;
18use tokio::task::JoinHandle;
19use tracing::warn;
20
21use super::agent::AgentRuntime;
22use super::event::AgentEvent;
23use super::event_store::{RuntimeEventStore, RuntimeEventStoreError};
24use super::run::RunId;
25use super::stream::{BoxRuntimeEventStream, RuntimeEventEnvelope, RuntimeRoom, RuntimeStreamError};
26use super::stream_adapter::RuntimeStreamAdapter;
27
28/// Replay page plus a live stream for a single subscription.
29///
30/// Callers are expected to drain `replay` first, then poll `live`. Overlap is
31/// possible (an event present in `replay` may also arrive via `live`); dedup
32/// via [`RuntimeEventEnvelope::event_id`](super::stream::RuntimeEventEnvelope::event_id)
33/// or `seq`.
34pub struct RuntimeSubscription {
35    /// Buffered replay events read from the store.
36    pub replay: Vec<RuntimeEventEnvelope>,
37    /// Live fanout stream for subsequent events.
38    pub live: BoxRuntimeEventStream,
39}
40
41/// Errors raised while assembling a [`RuntimeSubscription`].
42#[derive(Debug, Error)]
43pub enum RuntimeSubscriptionError {
44    /// Replay failed against the event store.
45    #[error(transparent)]
46    Replay(#[from] RuntimeEventStoreError),
47    /// Live subscription failed against the stream adapter.
48    #[error(transparent)]
49    Live(#[from] RuntimeStreamError),
50}
51
52/// Errors surfaced by a [`RuntimeEventBridge`] handle.
53#[derive(Debug, Error)]
54pub enum RuntimeEventBridgeError {
55    /// The bridge background task was cancelled.
56    #[error("runtime event bridge task was cancelled")]
57    Cancelled,
58    /// The bridge background task panicked.
59    #[error("runtime event bridge task panicked: {message}")]
60    TaskPanic {
61        /// Human-readable diagnostic.
62        message: String,
63    },
64}
65
66/// Combines replay and live fanout for runtime event streams.
67///
68/// `subscribe_run` reads a replay page from the store and opens a live
69/// subscription to the run room in one call. The caller owns the ordering
70/// decision (replay first, then live).
71pub struct RuntimeSubscriptionHub {
72    event_store: Arc<dyn RuntimeEventStore>,
73    stream_adapter: Arc<dyn RuntimeStreamAdapter>,
74}
75
76impl RuntimeSubscriptionHub {
77    /// Creates a hub over the given store and adapter.
78    #[must_use]
79    pub fn new(
80        event_store: Arc<dyn RuntimeEventStore>,
81        stream_adapter: Arc<dyn RuntimeStreamAdapter>,
82    ) -> Self {
83        Self {
84            event_store,
85            stream_adapter,
86        }
87    }
88
89    /// Returns replay events plus a live stream for `run_id`.
90    ///
91    /// The live subscription is opened first so no event published between
92    /// replay and subscription is lost; the caller deduplicates overlapping
93    /// events via [`RuntimeEventEnvelope::seq`](super::stream::RuntimeEventEnvelope::seq).
94    ///
95    /// # Errors
96    ///
97    /// Returns [`RuntimeSubscriptionError::Live`] when the stream adapter
98    /// refuses the live subscription, and
99    /// [`RuntimeSubscriptionError::Replay`] when the event store cannot
100    /// fulfil the replay request.
101    pub async fn subscribe_run(
102        &self,
103        run_id: RunId,
104        after_seq: Option<u64>,
105        limit: usize,
106    ) -> Result<RuntimeSubscription, RuntimeSubscriptionError> {
107        let live = self
108            .stream_adapter
109            .subscribe(RuntimeRoom::Run(run_id))
110            .await
111            .map_err(RuntimeSubscriptionError::Live)?;
112        let replay = self
113            .event_store
114            .list_after(run_id, after_seq, limit)
115            .await
116            .map_err(RuntimeSubscriptionError::Replay)?;
117        Ok(RuntimeSubscription { replay, live })
118    }
119}
120
121/// Drains [`AgentRuntime::subscribe`](super::agent::AgentRuntime::subscribe)
122/// into the event store and stream adapter.
123///
124/// The bridge appends each event to the store first; only on a successful
125/// append does it publish the envelope to the run room (and, when a session id
126/// is known, to the session room). Append failure therefore never produces a
127/// live event with an incomplete replay source.
128pub struct RuntimeEventBridge {
129    runtime: Arc<AgentRuntime>,
130    event_store: Arc<dyn RuntimeEventStore>,
131    stream_adapter: Arc<dyn RuntimeStreamAdapter>,
132}
133
134impl RuntimeEventBridge {
135    /// Creates a bridge wiring `runtime` to `event_store` and `stream_adapter`.
136    #[must_use]
137    pub fn new(
138        runtime: Arc<AgentRuntime>,
139        event_store: Arc<dyn RuntimeEventStore>,
140        stream_adapter: Arc<dyn RuntimeStreamAdapter>,
141    ) -> Self {
142        Self {
143            runtime,
144            event_store,
145            stream_adapter,
146        }
147    }
148
149    /// Spawns the background forwarding task and returns a handle.
150    ///
151    /// The task runs until the runtime's event broadcast closes or the handle
152    /// is dropped/aborted.
153    #[must_use]
154    pub fn spawn(self: Arc<Self>) -> RuntimeEventBridgeHandle {
155        let rx = self.runtime.subscribe();
156        let event_store = self.event_store.clone();
157        let stream_adapter = self.stream_adapter.clone();
158        let task = tokio::spawn(forward_events(rx, event_store, stream_adapter));
159        RuntimeEventBridgeHandle { task }
160    }
161}
162
163/// Forwarding loop shared by [`RuntimeEventBridge::spawn`] and tests.
164///
165/// Factored out so tests can drive the bridge with a synthetic
166/// [`broadcast::Receiver`] without constructing a full [`AgentRuntime`].
167async fn forward_events(
168    mut rx: broadcast::Receiver<AgentEvent>,
169    event_store: Arc<dyn RuntimeEventStore>,
170    stream_adapter: Arc<dyn RuntimeStreamAdapter>,
171) {
172    loop {
173        match rx.recv().await {
174            Ok(event) => {
175                let run_id = event.run_id();
176                match event_store.append(event).await {
177                    Ok(envelope) => {
178                        if let Err(error) = stream_adapter
179                            .publish(RuntimeRoom::Run(run_id), envelope.clone())
180                            .await
181                        {
182                            warn!(%error, "runtime event bridge publish to run room failed");
183                        }
184                        if let Some(session_id) = envelope.session_id
185                            && let Err(error) = stream_adapter
186                                .publish(RuntimeRoom::Session(session_id), envelope)
187                                .await
188                        {
189                            warn!(%error, "runtime event bridge publish to session room failed");
190                        }
191                    }
192                    Err(error) => {
193                        warn!(
194                            %error,
195                            "runtime event bridge append failed; skipping live publish"
196                        );
197                    }
198                }
199            }
200            Err(broadcast::error::RecvError::Closed) => break,
201            Err(broadcast::error::RecvError::Lagged(skipped)) => {
202                warn!(
203                    skipped,
204                    "runtime event bridge lagged behind runtime broadcast"
205                );
206            }
207        }
208    }
209}
210
211/// Handle to a running [`RuntimeEventBridge`] task.
212///
213/// Dropping the handle aborts the task. Call [`RuntimeEventBridgeHandle::abort`]
214/// to stop it explicitly, or [`RuntimeEventBridgeHandle::join`] to await
215/// completion.
216pub struct RuntimeEventBridgeHandle {
217    task: JoinHandle<()>,
218}
219
220impl RuntimeEventBridgeHandle {
221    /// Aborts the bridge task.
222    ///
223    /// Idempotent and safe to call from any thread. After abort,
224    /// [`join`](Self::join) returns [`RuntimeEventBridgeError::Cancelled`].
225    pub fn abort(&self) {
226        self.task.abort();
227    }
228
229    /// Returns `true` once the bridge task has finished.
230    #[must_use]
231    pub fn is_finished(&self) -> bool {
232        self.task.is_finished()
233    }
234
235    /// Awaits task completion.
236    ///
237    /// # Errors
238    ///
239    /// Returns [`RuntimeEventBridgeError::Cancelled`] if the task was
240    /// aborted, and [`RuntimeEventBridgeError::TaskPanic`] if it exited
241    /// unexpectedly (e.g. panic).
242    pub async fn join(mut self) -> Result<(), RuntimeEventBridgeError> {
243        match (&mut self.task).await {
244            Ok(()) => Ok(()),
245            Err(err) if err.is_cancelled() => Err(RuntimeEventBridgeError::Cancelled),
246            Err(_) => Err(RuntimeEventBridgeError::TaskPanic {
247                message: "bridge task exited unexpectedly".to_owned(),
248            }),
249        }
250    }
251}
252
253impl Drop for RuntimeEventBridgeHandle {
254    fn drop(&mut self) {
255        self.task.abort();
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    #![allow(clippy::unwrap_used, clippy::expect_used)]
262
263    use std::sync::atomic::{AtomicUsize, Ordering};
264    use std::time::Duration;
265
266    use chrono::Utc;
267    use futures_util::StreamExt;
268    use uuid::Uuid;
269
270    use super::*;
271    use crate::event::{RunCompleted, RunStarted};
272    use crate::event_store::{FailingRuntimeEventStore, MemoryRuntimeEventStore};
273    use crate::stream::RuntimeEventId;
274    use crate::stream_adapter::MemoryRuntimeStreamAdapter;
275    use behest_provider::{FinishReason, ModelName, ProviderId};
276
277    fn started(run: RunId, session_id: Uuid) -> AgentEvent {
278        AgentEvent::RunStarted(RunStarted {
279            run_id: run,
280            session_id,
281            provider: ProviderId::new("acme"),
282            model: ModelName::new("gpt-test"),
283            timestamp: Utc::now(),
284        })
285    }
286
287    fn terminal(run: RunId) -> AgentEvent {
288        AgentEvent::RunCompleted(RunCompleted {
289            run_id: run,
290            finish_reason: FinishReason::Stop,
291            iterations: 1,
292            timestamp: Utc::now(),
293        })
294    }
295
296    fn envelope(
297        run: RunId,
298        seq: u64,
299        session_id: Option<Uuid>,
300        event: AgentEvent,
301    ) -> RuntimeEventEnvelope {
302        RuntimeEventEnvelope {
303            event_id: RuntimeEventId::new(),
304            seq,
305            run_id: run,
306            session_id,
307            event,
308            emitted_at: Utc::now(),
309        }
310    }
311
312    #[tokio::test]
313    async fn hub_returns_replay_and_live() {
314        let store: Arc<dyn RuntimeEventStore> = Arc::new(MemoryRuntimeEventStore::new());
315        let adapter: Arc<dyn RuntimeStreamAdapter> = Arc::new(MemoryRuntimeStreamAdapter::new());
316
317        let run = RunId::new();
318        let sid = Uuid::now_v7();
319        store.append(started(run, sid)).await.unwrap();
320        store.append(terminal(run)).await.unwrap();
321
322        let hub = RuntimeSubscriptionHub::new(store.clone(), adapter.clone());
323        let mut sub = hub.subscribe_run(run, None, 10).await.unwrap();
324        assert_eq!(sub.replay.len(), 2);
325
326        // Publish a live event after subscribing; it must arrive on `live`.
327        adapter
328            .publish(
329                RuntimeRoom::Run(run),
330                envelope(run, 3, Some(sid), terminal(run)),
331            )
332            .await
333            .unwrap();
334        let received = tokio::time::timeout(Duration::from_secs(1), sub.live.next())
335            .await
336            .expect("timed out waiting for live event")
337            .expect("stream ended")
338            .expect("lagged");
339        assert_eq!(received.seq, 3);
340    }
341
342    #[tokio::test]
343    async fn bridge_persists_and_publishes_events() {
344        let (tx, rx) = broadcast::channel::<AgentEvent>(16);
345        let store: Arc<dyn RuntimeEventStore> = Arc::new(MemoryRuntimeEventStore::new());
346        let adapter: Arc<dyn RuntimeStreamAdapter> = Arc::new(MemoryRuntimeStreamAdapter::new());
347
348        let run = RunId::new();
349        let sid = Uuid::now_v7();
350        let mut live = adapter.subscribe(RuntimeRoom::Run(run)).await.unwrap();
351
352        let _handle = tokio::spawn(forward_events(rx, store.clone(), adapter.clone()));
353        tx.send(started(run, sid)).unwrap();
354
355        let received = tokio::time::timeout(Duration::from_secs(1), live.next())
356            .await
357            .expect("timed out waiting for live event")
358            .expect("stream ended")
359            .expect("lagged");
360        assert_eq!(received.seq, 1);
361        assert_eq!(received.session_id, Some(sid));
362
363        let replayed = store.list_after(run, None, 10).await.unwrap();
364        assert_eq!(replayed.len(), 1);
365        assert_eq!(replayed[0].seq, 1);
366    }
367
368    #[derive(Default)]
369    struct CountingRuntimeStreamAdapter {
370        publishes: AtomicUsize,
371    }
372
373    #[async_trait::async_trait]
374    impl RuntimeStreamAdapter for CountingRuntimeStreamAdapter {
375        async fn publish(
376            &self,
377            _room: RuntimeRoom,
378            _event: RuntimeEventEnvelope,
379        ) -> Result<(), RuntimeStreamError> {
380            self.publishes.fetch_add(1, Ordering::Relaxed);
381            Ok(())
382        }
383
384        async fn subscribe(
385            &self,
386            _room: RuntimeRoom,
387        ) -> Result<BoxRuntimeEventStream, RuntimeStreamError> {
388            Err(RuntimeStreamError::Subscribe {
389                message: "counting adapter does not support subscribe".to_owned(),
390            })
391        }
392    }
393
394    #[tokio::test]
395    async fn append_failure_does_not_publish_live_event() {
396        let (tx, rx) = broadcast::channel::<AgentEvent>(16);
397        let store: Arc<dyn RuntimeEventStore> = Arc::new(FailingRuntimeEventStore::new());
398        let adapter = Arc::new(CountingRuntimeStreamAdapter::default());
399
400        let run = RunId::new();
401        let sid = Uuid::now_v7();
402        let _handle = tokio::spawn(forward_events(rx, store.clone(), adapter.clone()));
403        tx.send(started(run, sid)).unwrap();
404
405        tokio::time::sleep(Duration::from_millis(50)).await;
406        assert_eq!(
407            adapter.publishes.load(Ordering::Relaxed),
408            0,
409            "no live event should be published when append fails"
410        );
411    }
412}