use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use chrono::Utc;
use tokio::sync::mpsc::error::TrySendError;
use tokio::sync::{RwLock, broadcast, mpsc};
use tracing::{debug, info, warn};
use crate::control::activity::ActivityParser;
use crate::control::backend::SessionBackend;
use crate::control::classifier::ActivityClassifier;
use crate::control::config::DEFAULT_AUTH_TIMEOUT_SECS;
use crate::control::event::{ActivityKind, BackendKind, SessionEvent, StopReason};
use crate::control::id::ControlSessionId;
use crate::control::state::{SessionMetadata, SessionState};
pub const DEFAULT_BROADCAST_CAPACITY: usize = 256;
pub const CRITICAL_OBSERVER_CAPACITY: usize = DEFAULT_BROADCAST_CAPACITY;
const CLASSIFIER_MIN_OUTPUT_LEN: usize = 20;
#[derive(Clone)]
pub struct SessionActorConfig {
pub auth_timeout: Duration,
pub classifier: Option<Arc<dyn ActivityClassifier>>,
}
impl Default for SessionActorConfig {
fn default() -> Self {
Self {
auth_timeout: Duration::from_secs(DEFAULT_AUTH_TIMEOUT_SECS),
classifier: None,
}
}
}
impl std::fmt::Debug for SessionActorConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SessionActorConfig")
.field("auth_timeout", &self.auth_timeout)
.field("classifier", &self.classifier.is_some())
.finish()
}
}
pub enum ActorCommand {
Send(crate::control::backend::SessionInput),
Stop,
ForceStop,
Subscribe(tokio::sync::oneshot::Sender<broadcast::Receiver<SessionEvent>>),
SubscribeCritical(tokio::sync::oneshot::Sender<mpsc::Receiver<SessionEvent>>),
}
impl std::fmt::Debug for ActorCommand {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Send(_) => f.write_str("Send"),
Self::Stop => f.write_str("Stop"),
Self::ForceStop => f.write_str("ForceStop"),
Self::Subscribe(_) => f.write_str("Subscribe"),
Self::SubscribeCritical(_) => f.write_str("SubscribeCritical"),
}
}
}
#[derive(Clone)]
pub struct SessionActorHandle {
pub command_tx: mpsc::Sender<ActorCommand>,
pub event_tx: broadcast::Sender<SessionEvent>,
pub write_lock_held: Arc<AtomicBool>,
pub metadata: Arc<RwLock<SessionMetadata>>,
}
impl SessionActorHandle {
pub fn try_acquire_write_lock(&self) -> bool {
self.write_lock_held
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
}
pub fn release_write_lock(&self) {
self.write_lock_held.store(false, Ordering::SeqCst);
}
pub async fn subscribe_critical(&self) -> Option<mpsc::Receiver<SessionEvent>> {
let (tx, rx) = tokio::sync::oneshot::channel();
self.command_tx
.send(ActorCommand::SubscribeCritical(tx))
.await
.ok()?;
rx.await.ok()
}
}
pub fn spawn_actor<B: SessionBackend>(
session_id: ControlSessionId,
project_id: String,
backend_kind: BackendKind,
backend: B,
broadcast_capacity: usize,
actor_config: SessionActorConfig,
) -> SessionActorHandle {
let (command_tx, command_rx) = mpsc::channel::<ActorCommand>(32);
let (event_tx, _) = broadcast::channel::<SessionEvent>(broadcast_capacity);
let write_lock_held = Arc::new(AtomicBool::new(false));
let metadata = Arc::new(RwLock::new(SessionMetadata::new(
session_id.clone(),
project_id,
backend_kind,
)));
let handle = SessionActorHandle {
command_tx,
event_tx: event_tx.clone(),
write_lock_held: Arc::clone(&write_lock_held),
metadata: Arc::clone(&metadata),
};
tokio::spawn(run_actor(
session_id,
backend,
backend_kind,
command_rx,
event_tx,
metadata,
actor_config,
));
handle
}
pub(crate) async fn run_actor<B: SessionBackend>(
session_id: ControlSessionId,
mut backend: B,
backend_kind: BackendKind,
mut command_rx: mpsc::Receiver<ActorCommand>,
event_tx: broadcast::Sender<SessionEvent>,
metadata: Arc<RwLock<SessionMetadata>>,
actor_config: SessionActorConfig,
) {
let _ = event_tx.send(SessionEvent::Started {
session_id: session_id.clone(),
backend: backend_kind,
ts: Utc::now(),
});
{
let mut m = metadata.write().await;
m.state = SessionState::Running;
}
let mut critical_tx: Option<mpsc::Sender<SessionEvent>> = None;
let mut auth_deadline: Option<tokio::time::Instant> = None;
let mut critical_total_dropped: u64 = 0;
loop {
let is_terminal = {
let m = metadata.read().await;
match m.state {
SessionState::AwaitingAuth => {
if auth_deadline.is_none() {
auth_deadline =
Some(tokio::time::Instant::now() + actor_config.auth_timeout);
}
}
_ => auth_deadline = None,
}
m.state.is_terminal()
};
if is_terminal {
break;
}
let auth_timer = async {
match auth_deadline {
Some(deadline) => tokio::time::sleep_until(deadline).await,
None => std::future::pending::<()>().await,
}
};
tokio::select! {
() = auth_timer, if auth_deadline.is_some() => {
warn!(
session_id = %session_id,
timeout_secs = actor_config.auth_timeout.as_secs(),
"auth timeout elapsed in AwaitingAuth (§4.4) — \
auto-stopping session as AuthFailed"
);
let now = Utc::now();
{
let mut m = metadata.write().await;
m.state = SessionState::AuthFailed;
}
let _ = event_tx.send(SessionEvent::AuthFailed {
session_id: session_id.clone(),
backend: backend_kind,
reason: format!(
"auth not completed within {}s; session auto-stopped (§4.4)",
actor_config.auth_timeout.as_secs()
),
ts: now,
});
if let Err(e) = backend.stop().await {
warn!(session_id = %session_id, "backend stop on auth timeout: {e}");
}
break;
}
cmd = command_rx.recv() => {
match cmd {
None => {
debug!(session_id = %session_id, "command channel closed; stopping actor");
break;
}
Some(ActorCommand::Stop) => {
debug!(session_id = %session_id, "actor: graceful Stop received");
transition_to_stopping(&metadata).await;
if let Err(e) = backend.stop().await {
warn!(session_id = %session_id, "backend stop error: {e}");
}
transition_to_stopped(
&session_id, StopReason::Requested, &event_tx, &metadata,
).await;
break;
}
Some(ActorCommand::ForceStop) => {
debug!(session_id = %session_id, "actor: ForceStop received");
if let Err(e) = backend.stop().await {
warn!(session_id = %session_id, "backend force-stop error: {e}");
}
transition_to_stopped(
&session_id, StopReason::Forced, &event_tx, &metadata,
).await;
break;
}
Some(ActorCommand::Send(input)) => {
if let Err(e) = backend.send(input).await {
warn!(session_id = %session_id, "backend send error: {e}");
}
}
Some(ActorCommand::Subscribe(reply_tx)) => {
let _ = reply_tx.send(event_tx.subscribe());
}
Some(ActorCommand::SubscribeCritical(reply_tx)) => {
if let Some(ref existing_tx) = critical_tx {
if !existing_tx.is_closed() {
warn!(
session_id = %session_id,
"SubscribeCritical rejected: \
a connected critical observer already exists (§6.1). \
Only one critical observer is supported at a time."
);
drop(reply_tx);
} else {
let (tx, rx) =
mpsc::channel::<SessionEvent>(CRITICAL_OBSERVER_CAPACITY);
let _ = reply_tx.send(rx);
critical_tx = Some(tx);
critical_total_dropped = 0;
debug!(
session_id = %session_id,
"critical observer replaced (prior was disconnected, §6.1)"
);
}
} else {
let (tx, rx) =
mpsc::channel::<SessionEvent>(CRITICAL_OBSERVER_CAPACITY);
let _ = reply_tx.send(rx);
critical_tx = Some(tx);
critical_total_dropped = 0;
debug!(session_id = %session_id, "critical observer subscribed (§6.1)");
}
}
}
}
maybe_events = backend.recv() => {
match maybe_events {
None => {
debug!(session_id = %session_id, "backend recv returned None (clean exit)");
transition_to_stopped(
&session_id, StopReason::Completed, &event_tx, &metadata,
).await;
break;
}
Some(Err(e)) => {
warn!(session_id = %session_id, "backend recv error: {e}");
let _ = event_tx.send(SessionEvent::Failed {
session_id: session_id.clone(),
reason: e.to_string(),
ts: Utc::now(),
});
{
let mut m = metadata.write().await;
m.state = SessionState::Failed;
}
break;
}
Some(Ok(events)) => {
let now = Utc::now();
let mut critical_lagged = false;
let mut raw_text = String::new();
for event in &events {
if let SessionEvent::Output { raw, .. } = event {
raw_text.push_str(raw);
raw_text.push('\n');
}
let _ = event_tx.send(event.clone());
if let Some(ref tx) = critical_tx {
match tx.try_send(event.clone()) {
Ok(()) => {}
Err(TrySendError::Full(_)) => {
critical_total_dropped += 1;
critical_lagged = true;
}
Err(TrySendError::Closed(_)) => {
info!(
session_id = %session_id,
"critical observer disconnected (Closed) — \
clearing critical_tx, session continues (§6.1)"
);
critical_tx = None;
}
}
}
}
{
let mut m = metadata.write().await;
m.last_activity_at = now;
}
if critical_lagged {
warn!(
session_id = %session_id,
"critical observer lagged (§6.1) — \
transitioning to AwaitingIntervention"
);
let _ = event_tx.send(SessionEvent::ObserverCriticalLag {
session_id: session_id.clone(),
dropped: critical_total_dropped,
});
{
let mut m = metadata.write().await;
m.state = SessionState::AwaitingIntervention;
}
if let Err(e) = backend.stop().await {
warn!(session_id = %session_id, "backend stop on lag: {e}");
}
let _ = event_tx.send(SessionEvent::Stopped {
session_id: session_id.clone(),
reason: StopReason::Forced,
ts: Utc::now(),
});
break;
}
if !raw_text.is_empty()
&& let Some(parsed) = ActivityParser::parse_output(&raw_text)
{
if parsed.kind == ActivityKind::AuthPrompt {
let _ = event_tx.send(SessionEvent::ActivityParsed {
session_id: session_id.clone(),
kind: parsed.kind.clone(),
summary: parsed.summary.clone(),
ts: now,
});
if backend_kind == BackendKind::StreamJson {
{
let mut m = metadata.write().await;
m.last_summary = Some(parsed.summary.clone());
m.state = SessionState::AuthFailed;
}
let _ = event_tx.send(SessionEvent::AuthFailed {
session_id: session_id.clone(),
backend: backend_kind,
reason: "auth prompt detected on stream-json; \
pre-authenticate ~/.claude and re-launch"
.into(),
ts: now,
});
break;
} else if backend_kind == BackendKind::Tmux {
let mut m = metadata.write().await;
m.last_summary = Some(parsed.summary.clone());
m.state = SessionState::AwaitingAuth;
}
continue;
}
{
let mut m = metadata.write().await;
m.last_summary = Some(parsed.summary.clone());
if parsed.pending_decision.is_some() {
m.pending_decision = parsed.pending_decision.clone();
m.proposed_default = parsed.proposed_default.clone();
} else {
m.pending_decision = None;
m.proposed_default = None;
}
}
let _ = event_tx.send(SessionEvent::ActivityParsed {
session_id: session_id.clone(),
kind: parsed.kind.clone(),
summary: parsed.summary.clone(),
ts: now,
});
if let Some(ref prompt) = parsed.pending_decision {
let _ = event_tx.send(SessionEvent::PendingDecision {
session_id: session_id.clone(),
prompt: prompt.clone(),
proposed_default: parsed.proposed_default.clone(),
ts: now,
});
}
} else if raw_text.len() >= CLASSIFIER_MIN_OUTPUT_LEN
&& let Some(classifier) = actor_config.classifier.as_ref()
{
classify_fallback(
classifier.as_ref(),
&session_id,
&raw_text,
&event_tx,
&metadata,
)
.await;
}
}
}
}
}
}
debug!(session_id = %session_id, "actor task exiting");
}
async fn classify_fallback(
classifier: &dyn ActivityClassifier,
session_id: &ControlSessionId,
raw_text: &str,
event_tx: &broadcast::Sender<SessionEvent>,
metadata: &Arc<RwLock<SessionMetadata>>,
) {
match classifier.classify(raw_text).await {
Ok(Some(kind)) => {
let classified_at = Utc::now();
let summary = format!("llm-classified: {kind:?}");
{
let mut m = metadata.write().await;
m.last_summary = Some(summary.clone());
}
let _ = event_tx.send(SessionEvent::ActivityParsed {
session_id: session_id.clone(),
kind,
summary,
ts: classified_at,
});
}
Ok(None) => {
debug!(
session_id = %session_id,
"§8.3 classifier returned no-match (ok-none); skipping ActivityParsed"
);
}
Err(reason) => {
warn!(
session_id = %session_id,
"§8.3 classifier failed (fail-open, session continues): {reason}"
);
}
}
}
async fn transition_to_stopping(metadata: &Arc<RwLock<SessionMetadata>>) {
let mut m = metadata.write().await;
m.state = SessionState::Stopping;
}
async fn transition_to_stopped(
session_id: &ControlSessionId,
reason: StopReason,
event_tx: &broadcast::Sender<SessionEvent>,
metadata: &Arc<RwLock<SessionMetadata>>,
) {
{
let mut m = metadata.write().await;
m.state = SessionState::Stopped;
}
let _ = event_tx.send(SessionEvent::Stopped {
session_id: session_id.clone(),
reason,
ts: Utc::now(),
});
}
#[cfg(test)]
#[path = "actor_tests.rs"]
mod tests;