use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, PoisonError, RwLock};
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum LifecycleEvent {
SessionStarted { session_id: String },
Waiting,
TurnStarted,
Thinking,
ToolActivity { tool: String },
Blocked,
Unblocked,
TurnCompleted,
TurnFailed { reason: Option<String> },
TurnCancelled,
SessionEnded,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LifecycleEnvelope {
pub session_id: Option<String>,
pub event: LifecycleEvent,
}
type Observer = Arc<dyn Fn(&LifecycleEnvelope) + Send + Sync>;
static OBSERVERS: RwLock<Vec<(u64, Option<String>, Observer)>> = RwLock::new(Vec::new());
static ACTIVE_SESSION: RwLock<Option<String>> = RwLock::new(None);
static COUNT: AtomicUsize = AtomicUsize::new(0);
static NEXT_ID: AtomicU64 = AtomicU64::new(0);
#[derive(Debug)]
pub struct Subscription {
id: u64,
}
impl Drop for Subscription {
fn drop(&mut self) {
let mut guard = OBSERVERS.write().unwrap_or_else(PoisonError::into_inner);
guard.retain(|(id, _, _)| *id != self.id);
COUNT.store(guard.len(), Ordering::Release);
}
}
pub fn subscribe(observer: impl Fn(&LifecycleEnvelope) + Send + Sync + 'static) -> Subscription {
register(None, Arc::new(observer))
}
pub fn subscribe_session(
session_id: impl Into<String>,
observer: impl Fn(&LifecycleEnvelope) + Send + Sync + 'static,
) -> Subscription {
register(Some(session_id.into()), Arc::new(observer))
}
fn register(want: Option<String>, observer: Observer) -> Subscription {
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
let mut guard = OBSERVERS.write().unwrap_or_else(PoisonError::into_inner);
guard.push((id, want, observer));
COUNT.store(guard.len(), Ordering::Release);
Subscription { id }
}
#[must_use]
pub fn new_session_id() -> SessionId {
SessionId(format!("session-{}", crate::new_conversation_id()))
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct SessionId(String);
impl SessionId {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn from_issued(id: impl Into<String>) -> Self {
Self(id.into())
}
}
impl std::fmt::Display for SessionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
pub fn set_active_session(session_id: &SessionId) {
*ACTIVE_SESSION
.write()
.unwrap_or_else(PoisonError::into_inner) = Some(session_id.as_str().to_string());
}
pub fn clear_active_session() {
*ACTIVE_SESSION
.write()
.unwrap_or_else(PoisonError::into_inner) = None;
}
#[must_use]
pub fn active_session() -> Option<String> {
if let Some(scoped) = current_thread_session() {
return Some(scoped);
}
ACTIVE_SESSION
.read()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
std::thread_local! {
static THREAD_SESSION: std::cell::RefCell<Option<String>> =
const { std::cell::RefCell::new(None) };
}
fn current_thread_session() -> Option<String> {
THREAD_SESSION
.try_with(|slot| slot.borrow().clone())
.ok()
.flatten()
}
#[must_use]
pub struct ScopedActiveSession {
previous: Option<String>,
_thread_bound: std::marker::PhantomData<std::rc::Rc<()>>,
}
impl Drop for ScopedActiveSession {
fn drop(&mut self) {
let previous = self.previous.take();
let _ = THREAD_SESSION.try_with(|slot| *slot.borrow_mut() = previous);
}
}
pub fn scoped_active_session(session_id: &SessionId) -> ScopedActiveSession {
let previous =
THREAD_SESSION.with(|slot| slot.borrow_mut().replace(session_id.as_str().to_string()));
ScopedActiveSession {
previous,
_thread_bound: std::marker::PhantomData,
}
}
#[must_use]
pub fn observed() -> bool {
COUNT.load(Ordering::Acquire) > 0
}
pub fn emit(event: LifecycleEvent) {
if !observed() {
return;
}
emit_for(active_session(), event);
}
pub fn emit_for(session_id: Option<String>, event: LifecycleEvent) {
if !observed() {
return;
}
let envelope = LifecycleEnvelope { session_id, event };
let observers: Vec<Observer> = {
let guard = OBSERVERS.read().unwrap_or_else(PoisonError::into_inner);
guard
.iter()
.filter(|(_, want, _)| match want {
None => true,
Some(want) => envelope.session_id.as_deref() == Some(want.as_str()),
})
.map(|(_, _, o)| Arc::clone(o))
.collect()
};
for observer in observers {
let _ = catch_unwind(AssertUnwindSafe(|| observer(&envelope)));
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
fn collector() -> (Subscription, Arc<Mutex<Vec<LifecycleEvent>>>) {
let seen = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&seen);
let mine = std::thread::current().id();
let sub = subscribe(move |e| {
if std::thread::current().id() == mine {
sink.lock().unwrap().push(e.event.clone());
}
});
(sub, seen)
}
fn session_collector(id: &str) -> (Subscription, Arc<Mutex<Vec<LifecycleEvent>>>) {
let seen = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&seen);
let mine = std::thread::current().id();
let sub = subscribe_session(id, move |e| {
if std::thread::current().id() == mine {
sink.lock().unwrap().push(e.event.clone());
}
});
(sub, seen)
}
#[allow(clippy::type_complexity)]
fn cross_thread_collector(
want: Vec<String>,
) -> (
Subscription,
Arc<Mutex<Vec<(Option<String>, LifecycleEvent)>>>,
) {
let seen = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&seen);
let sub = subscribe(move |e| {
if e.session_id
.as_deref()
.is_some_and(|id| want.iter().any(|w| w == id))
{
sink.lock()
.unwrap()
.push((e.session_id.clone(), e.event.clone()));
}
});
(sub, seen)
}
#[test]
fn two_threads_under_their_own_scopes_partition_their_events() {
let _g = crate::test_guard::GlobalSettingsGuard::acquire();
let a = new_session_id();
let b = new_session_id();
let (_sub, seen) =
cross_thread_collector(vec![a.as_str().to_string(), b.as_str().to_string()]);
let bystander = new_session_id();
set_active_session(&bystander);
let workers: Vec<_> = [a.clone(), b.clone()]
.into_iter()
.map(|id| {
std::thread::spawn(move || {
let _scope = scoped_active_session(&id);
for _ in 0..25 {
emit(LifecycleEvent::Thinking);
emit(LifecycleEvent::ToolActivity {
tool: "run_command".into(),
});
}
})
})
.collect();
for w in workers {
w.join().expect("worker");
}
clear_active_session();
let got = seen.lock().unwrap().clone();
let for_a = got
.iter()
.filter(|(id, _)| id.as_deref() == Some(a.as_str()))
.count();
let for_b = got
.iter()
.filter(|(id, _)| id.as_deref() == Some(b.as_str()))
.count();
assert_eq!(for_a, 50, "every event A emitted is attributed to A");
assert_eq!(for_b, 50, "every event B emitted is attributed to B");
assert_eq!(
for_a + for_b,
got.len(),
"and nothing else leaked into either stream"
);
}
#[test]
fn a_thread_without_a_scope_does_not_inherit_another_threads_session() {
let _g = crate::test_guard::GlobalSettingsGuard::acquire();
let scoped = new_session_id();
let ambient = new_session_id();
let (_sub, seen) = cross_thread_collector(vec![
scoped.as_str().to_string(),
ambient.as_str().to_string(),
]);
set_active_session(&ambient);
let scoped_for_thread = scoped.clone();
std::thread::spawn(move || {
let _scope = scoped_active_session(&scoped_for_thread);
emit(LifecycleEvent::Thinking);
})
.join()
.expect("scoped worker");
std::thread::spawn(|| emit(LifecycleEvent::Blocked))
.join()
.expect("unscoped worker");
clear_active_session();
let got = seen.lock().unwrap().clone();
assert!(
got.iter()
.any(|(id, e)| id.as_deref() == Some(scoped.as_str())
&& matches!(e, LifecycleEvent::Thinking)),
"the scoped thread's event is its own"
);
assert!(
got.iter()
.any(|(id, e)| id.as_deref() == Some(ambient.as_str())
&& matches!(e, LifecycleEvent::Blocked)),
"the unscoped thread falls back to the process cell"
);
}
#[test]
fn scopes_nest_and_restore() {
let outer = new_session_id();
let inner = new_session_id();
assert_eq!(current_thread_session(), None);
{
let _o = scoped_active_session(&outer);
assert_eq!(current_thread_session().as_deref(), Some(outer.as_str()));
{
let _i = scoped_active_session(&inner);
assert_eq!(current_thread_session().as_deref(), Some(inner.as_str()));
}
assert_eq!(
current_thread_session().as_deref(),
Some(outer.as_str()),
"the inner scope restored the outer one, not None"
);
}
assert_eq!(current_thread_session(), None, "and the thread is clean");
}
#[test]
fn the_thread_scope_wins_over_the_process_cell() {
let _g = crate::test_guard::GlobalSettingsGuard::acquire();
let visible = new_session_id();
let working = new_session_id();
set_active_session(&visible);
assert_eq!(active_session().as_deref(), Some(visible.as_str()));
{
let _scope = scoped_active_session(&working);
assert_eq!(
active_session().as_deref(),
Some(working.as_str()),
"work running here belongs to the session that owns this thread"
);
}
assert_eq!(
active_session().as_deref(),
Some(visible.as_str()),
"and the process cell is intact afterward"
);
clear_active_session();
}
#[test]
fn events_reach_subscribers_and_stop_at_unsubscribe() {
let (sub, seen) = collector();
emit(LifecycleEvent::TurnStarted);
emit(LifecycleEvent::ToolActivity {
tool: "read_file".into(),
});
drop(sub);
emit(LifecycleEvent::TurnCompleted);
assert_eq!(
*seen.lock().unwrap(),
vec![
LifecycleEvent::TurnStarted,
LifecycleEvent::ToolActivity {
tool: "read_file".into()
}
],
"a dropped subscription must stop receiving"
);
}
#[test]
fn subscription_registration_is_exact() {
let sub = subscribe(|_| {});
let id = sub.id;
assert!(observed());
assert!(OBSERVERS
.read()
.unwrap()
.iter()
.any(|(entry, _, _)| *entry == id));
drop(sub);
assert!(
!OBSERVERS
.read()
.unwrap()
.iter()
.any(|(entry, _, _)| *entry == id),
"drop must unregister exactly this observer"
);
}
#[test]
fn every_event_carries_its_session_not_just_the_start() {
let seen: Arc<Mutex<Vec<Option<String>>>> = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&seen);
let mine = std::thread::current().id();
let sub = subscribe(move |e| {
if std::thread::current().id() == mine {
sink.lock().unwrap().push(e.session_id.clone());
}
});
emit_for(Some("sess-A".into()), LifecycleEvent::Thinking);
emit_for(Some("sess-A".into()), LifecycleEvent::TurnCompleted);
drop(sub);
assert_eq!(
*seen.lock().unwrap(),
vec![Some("sess-A".to_string()), Some("sess-A".to_string())],
"identity rides every event"
);
}
#[test]
fn a_thinking_does_not_reach_b() {
let (sub_a, a) = session_collector("A");
let (sub_b, b) = session_collector("B");
emit_for(Some("A".into()), LifecycleEvent::Thinking);
drop(sub_a);
drop(sub_b);
assert_eq!(*a.lock().unwrap(), vec![LifecycleEvent::Thinking]);
assert!(
b.lock().unwrap().is_empty(),
"B must not observe A's Thinking"
);
}
#[test]
fn b_waiting_does_not_reach_a() {
let (sub_a, a) = session_collector("A");
let (sub_b, b) = session_collector("B");
emit_for(Some("B".into()), LifecycleEvent::Waiting);
drop(sub_a);
drop(sub_b);
assert_eq!(*b.lock().unwrap(), vec![LifecycleEvent::Waiting]);
assert!(
a.lock().unwrap().is_empty(),
"A must not observe B's Waiting — B going idle cannot idle A"
);
}
#[test]
fn a_session_ended_does_not_end_b_and_b_keeps_reporting() {
let (sub_a, a) = session_collector("A");
let (sub_b, b) = session_collector("B");
emit_for(Some("A".into()), LifecycleEvent::SessionEnded);
emit_for(Some("B".into()), LifecycleEvent::TurnStarted);
emit_for(Some("B".into()), LifecycleEvent::TurnCompleted);
drop(sub_a);
drop(sub_b);
assert_eq!(*a.lock().unwrap(), vec![LifecycleEvent::SessionEnded]);
assert_eq!(
*b.lock().unwrap(),
vec![LifecycleEvent::TurnStarted, LifecycleEvent::TurnCompleted],
"B continues reporting after A ended"
);
}
#[test]
fn an_unscoped_event_is_not_a_wildcard() {
let (sub_a, a) = session_collector("A");
let (sub_all, all) = collector();
emit_for(None, LifecycleEvent::Waiting);
drop(sub_a);
drop(sub_all);
assert!(a.lock().unwrap().is_empty(), "filtered gets nothing");
assert_eq!(
*all.lock().unwrap(),
vec![LifecycleEvent::Waiting],
"unfiltered still gets it"
);
}
#[test]
fn the_active_session_stamps_the_infrastructure_emitters() {
let _g = crate::test_guard::GlobalSettingsGuard::acquire();
let (sub, seen) = session_collector("owner");
let owner = SessionId::from_issued("owner");
set_active_session(&owner);
emit(LifecycleEvent::Blocked);
clear_active_session();
emit(LifecycleEvent::Unblocked); drop(sub);
assert_eq!(
*seen.lock().unwrap(),
vec![LifecycleEvent::Blocked],
"scoped while owned; unscoped after clearing"
);
}
#[test]
fn concurrently_created_sessions_have_distinct_identities() {
let ids: std::collections::BTreeSet<String> =
(0..64).map(|_| new_session_id().to_string()).collect();
assert_eq!(ids.len(), 64, "every session identity must be unique");
let handles: Vec<_> = (0..8)
.map(|_| std::thread::spawn(|| new_session_id().to_string()))
.collect();
let threaded: std::collections::BTreeSet<String> =
handles.into_iter().map(|h| h.join().unwrap()).collect();
assert_eq!(threaded.len(), 8, "concurrent creation must not collide");
}
#[test]
fn a_conversation_switch_cannot_misattribute_later_events() {
let _g = crate::test_guard::GlobalSettingsGuard::acquire();
let session = new_session_id();
let (sub, seen) = session_collector(session.as_str());
set_active_session(&session);
emit_for(
Some(session.to_string()),
LifecycleEvent::SessionStarted {
session_id: session.to_string(),
},
);
emit(LifecycleEvent::Thinking);
for _ in 0..3 {
let _conversation = crate::new_conversation_id();
}
emit(LifecycleEvent::Thinking);
emit(LifecycleEvent::ToolActivity {
tool: "run_command".to_string(),
});
emit(LifecycleEvent::TurnCompleted);
drop(sub);
let got = seen.lock().unwrap().clone();
assert!(
got.contains(&LifecycleEvent::TurnCompleted),
"events after a conversation switch must still reach the session: {got:?}"
);
assert_eq!(
got.iter()
.filter(|e| matches!(e, LifecycleEvent::Thinking))
.count(),
2,
"both Thinking events belong to this session: {got:?}"
);
assert_eq!(
active_session().as_deref(),
Some(session.as_str()),
"a conversation switch must not re-anchor lifecycle ownership"
);
}
#[test]
#[serial_test::serial(panic_hook)]
fn a_panicking_observer_cannot_break_the_agent() {
let (sub, seen) = collector();
let boom = subscribe(|_| panic!("integration bug"));
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
emit(LifecycleEvent::Waiting);
std::panic::set_hook(hook);
drop(boom);
drop(sub);
assert_eq!(
seen.lock().unwrap().last(),
Some(&LifecycleEvent::Waiting),
"the surviving observer still receives the event"
);
}
}