use std::collections::VecDeque;
use std::sync::{Arc, Condvar, Mutex};
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
#[serde(tag = "event")]
pub enum RunEvent {
RunStarted { run_id: String },
RunResumed { run_id: String },
NodeDispatched {
superstep: u64,
node: String,
task_path: String,
attempt: u32,
effect_seq: u32,
#[serde(skip_serializing_if = "Option::is_none")]
effect_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
executor_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
agent_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f64>,
},
EffectSettled {
superstep: u64,
node: String,
task_path: String,
attempt: u32,
effect_seq: u32,
ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
input_tokens: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
output_tokens: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
finish_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error_type: Option<String>,
},
AskRaised { superstep: u64, node: String, tool_call_id: String },
CheckpointWritten { superstep: u64, hash: String },
TokenChunk { node: String, task_path: String, attempt: u32, text: String },
RunFinished { run_id: String, outcome: String, dropped_events: u64 },
}
pub trait RunObserver: Send + Sync {
fn event(&self, ev: &RunEvent);
}
struct Queue {
buf: VecDeque<RunEvent>,
dropped: u64,
closed: bool,
}
struct Shared {
q: Mutex<Queue>,
cv: Condvar,
}
pub(crate) struct EventBus {
shared: Arc<Shared>,
capacity: usize,
worker: Option<std::thread::JoinHandle<()>>,
}
pub(crate) const EVENT_BUFFER: usize = 1024;
impl EventBus {
pub fn new(observer: Arc<dyn RunObserver>, capacity: usize) -> Self {
let shared = Arc::new(Shared {
q: Mutex::new(Queue { buf: VecDeque::new(), dropped: 0, closed: false }),
cv: Condvar::new(),
});
let worker_shared = Arc::clone(&shared);
let worker = std::thread::spawn(move || loop {
let ev = {
let mut q = worker_shared.q.lock().unwrap_or_else(|e| e.into_inner());
loop {
if let Some(ev) = q.buf.pop_front() {
break ev;
}
if q.closed {
return;
}
q = worker_shared
.cv
.wait(q)
.unwrap_or_else(|e| e.into_inner());
}
};
observer.event(&ev);
});
EventBus { shared, capacity, worker: Some(worker) }
}
pub fn emit(&self, ev: RunEvent) {
let mut q = self.shared.q.lock().unwrap_or_else(|e| e.into_inner());
if q.buf.len() >= self.capacity {
q.buf.pop_front();
q.dropped += 1;
}
q.buf.push_back(ev);
drop(q);
self.shared.cv.notify_one();
}
pub fn dropped(&self) -> u64 {
self.shared.q.lock().unwrap_or_else(|e| e.into_inner()).dropped
}
}
impl Drop for EventBus {
fn drop(&mut self) {
{
let mut q = self.shared.q.lock().unwrap_or_else(|e| e.into_inner());
q.closed = true;
}
self.shared.cv.notify_one();
if let Some(w) = self.worker.take() {
let _ = w.join();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
struct Blocking {
entered: mpsc::Sender<()>,
release: Mutex<mpsc::Receiver<()>>,
seen: Mutex<Vec<RunEvent>>,
}
impl RunObserver for Blocking {
fn event(&self, ev: &RunEvent) {
let first = {
let mut seen = self.seen.lock().unwrap();
seen.push(ev.clone());
seen.len() == 1
};
if first {
let _ = self.entered.send(());
let _ = self.release.lock().unwrap().recv();
}
}
}
#[test]
fn bounded_buffer_drops_oldest_and_counts() {
let (entered_tx, entered_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
let obs = Arc::new(Blocking {
entered: entered_tx,
release: Mutex::new(release_rx),
seen: Mutex::new(Vec::new()),
});
let bus = EventBus::new(Arc::clone(&obs) as Arc<dyn RunObserver>, 4);
let ev = |i: u64| RunEvent::CheckpointWritten { superstep: i, hash: String::new() };
bus.emit(ev(1));
entered_rx.recv().unwrap();
for i in 2..=10 {
bus.emit(ev(i));
}
assert_eq!(bus.dropped(), 5, "events 2..=6 were discarded, oldest first");
release_tx.send(()).unwrap();
drop(bus);
let seen = obs.seen.lock().unwrap();
let got: Vec<u64> = seen
.iter()
.map(|e| match e {
RunEvent::CheckpointWritten { superstep, .. } => *superstep,
_ => unreachable!(),
})
.collect();
assert_eq!(got, vec![1, 7, 8, 9, 10], "in-flight + the newest four");
}
}