use std::sync::Arc;
use chrono::{DateTime, Utc};
use ractor::concurrency::JoinHandle;
use ractor::{Actor, ActorProcessingErr, ActorRef, MessagingErr};
use crate::daemon_protocol::{IterationLogEntry, PendingReplyEntry};
use crate::state::AppState;
const MILD_STALL_MULTIPLIER: i64 = 3;
const HARD_STALL_MULTIPLIER: i64 = 10;
const HARD_STALL_CAP_SECS: u64 = 1800;
pub fn compute_average_loop_interval(log: &[IterationLogEntry]) -> Option<i64> {
if log.len() < 3 {
return None;
}
let intervals: Vec<i64> = log
.windows(2)
.map(|w| w[1].timestamp - w[0].timestamp)
.collect();
let sum: i64 = intervals.iter().sum();
Some(sum / intervals.len() as i64)
}
#[derive(Debug)]
pub enum SessionMsg {
Stopped,
Active,
GetPendingReplies(ractor::RpcReplyPort<Vec<PendingReplyEntry>>),
Renamed { new_id: String },
IdleTimeout,
#[allow(dead_code)]
LoopProgress,
LoopMildStall,
LoopHardStall,
ClearReminder { clearing_id: u64 },
SetPendingCompactContinuation(String),
DrainPendingCompactContinuation(ractor::RpcReplyPort<Option<String>>),
WatchdogTimeout,
}
pub struct SessionAgentState {
pub session_id: String,
pub pane: String,
pub idle: bool,
pub last_stopped_at: Option<DateTime<Utc>>,
pub last_active_at: Option<DateTime<Utc>>,
idle_timer: Option<JoinHandle<Result<(), MessagingErr<SessionMsg>>>>,
loop_mild_timer: Option<JoinHandle<Result<(), MessagingErr<SessionMsg>>>>,
loop_hard_timer: Option<JoinHandle<Result<(), MessagingErr<SessionMsg>>>>,
pub reminder_cleared: bool,
next_clearing_id: u64,
watchdog_timer: Option<JoinHandle<Result<(), MessagingErr<SessionMsg>>>>,
pub pending_compact_continuation: Option<String>,
}
impl std::fmt::Debug for SessionAgentState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SessionAgentState")
.field("session_id", &self.session_id)
.field("pane", &self.pane)
.field("idle", &self.idle)
.finish_non_exhaustive()
}
}
impl SessionAgentState {
pub fn new(session_id: String, pane: String) -> Self {
Self {
session_id,
pane,
idle: false,
last_stopped_at: None,
last_active_at: None,
idle_timer: None,
loop_mild_timer: None,
loop_hard_timer: None,
reminder_cleared: false,
next_clearing_id: 0,
watchdog_timer: None,
pending_compact_continuation: None,
}
}
}
#[derive(Debug)]
pub struct SessionAgent {
pub app_state: Arc<AppState>,
}
#[derive(Debug)]
pub struct SessionAgentArgs {
pub session_id: String,
pub pane: String,
}
#[ractor::async_trait]
impl Actor for SessionAgent {
type Msg = SessionMsg;
type State = SessionAgentState;
type Arguments = SessionAgentArgs;
async fn pre_start(
&self,
_myself: ActorRef<Self::Msg>,
args: Self::Arguments,
) -> Result<Self::State, ActorProcessingErr> {
tracing::info!("session agent started: {}", args.session_id);
Ok(SessionAgentState::new(args.session_id, args.pane))
}
async fn handle(
&self,
myself: ActorRef<Self::Msg>,
message: Self::Msg,
state: &mut Self::State,
) -> Result<(), ActorProcessingErr> {
match message {
SessionMsg::Stopped => {
state.last_stopped_at = Some(Utc::now());
if let Some(h) = state.idle_timer.take() {
h.abort();
}
let timeout = self.app_state.settings.read().await.idle_timeout_secs;
if let Some(h) = state.watchdog_timer.take() {
h.abort();
}
let (has_pending, has_reminder) = {
let proto = self.app_state.protocol.read().await;
let pending = proto
.pending_replies
.get(&state.session_id)
.map(|p| !p.is_empty())
.unwrap_or(false);
let reminder = proto
.sessions
.get(&state.session_id)
.and_then(|s| s.metadata.reminder.as_ref())
.is_some();
(pending, reminder)
};
if has_pending || has_reminder {
state.idle_timer = Some(
myself.send_after(std::time::Duration::from_secs(timeout), || {
SessionMsg::IdleTimeout
}),
);
state.watchdog_timer = Some(
myself.send_after(std::time::Duration::from_secs(timeout * 2), || {
SessionMsg::WatchdogTimeout
}),
);
}
if has_pending {
let cutoff = Utc::now().timestamp() - timeout as i64;
let pending = self
.app_state
.protocol
.read()
.await
.pending_replies
.get(&state.session_id)
.cloned()
.unwrap_or_default();
let overdue: Vec<&PendingReplyEntry> = pending
.iter()
.filter(|p| p.last_activity < cutoff)
.collect();
if !overdue.is_empty() {
self.send_reminders(&overdue, state).await;
}
}
}
SessionMsg::Active => {
state.idle = false;
state.reminder_cleared = false;
state.last_active_at = Some(Utc::now());
if let Some(h) = state.idle_timer.take() {
h.abort();
}
if let Some(h) = state.watchdog_timer.take() {
h.abort();
}
}
SessionMsg::GetPendingReplies(reply) => {
if !reply.is_closed() {
let pending = self
.app_state
.protocol
.read()
.await
.pending_replies
.get(&state.session_id)
.cloned()
.unwrap_or_default();
let _ = reply.send(pending);
}
}
SessionMsg::Renamed { new_id } => {
tracing::info!(
old = %state.session_id,
new = %new_id,
"session agent renamed"
);
state.session_id = new_id;
}
SessionMsg::LoopProgress => {
if let Some(h) = state.loop_mild_timer.take() {
h.abort();
}
if let Some(h) = state.loop_hard_timer.take() {
h.abort();
}
let avg = {
let proto = self.app_state.protocol.read().await;
proto
.sessions
.get(&state.session_id)
.map(|s| compute_average_loop_interval(&s.metadata.iteration_log))
.unwrap_or(None)
};
if let Some(avg) = avg {
let mild_secs = (avg * MILD_STALL_MULTIPLIER) as u64;
let hard_secs = ((avg * HARD_STALL_MULTIPLIER) as u64).min(HARD_STALL_CAP_SECS);
state.loop_mild_timer = Some(
myself.send_after(std::time::Duration::from_secs(mild_secs), || {
SessionMsg::LoopMildStall
}),
);
state.loop_hard_timer = Some(
myself.send_after(std::time::Duration::from_secs(hard_secs), || {
SessionMsg::LoopHardStall
}),
);
tracing::debug!(
session = %state.session_id,
avg_interval = avg,
mild_timeout = mild_secs,
hard_timeout = hard_secs,
"loop stall timers set"
);
}
}
SessionMsg::LoopMildStall => {
state.loop_mild_timer = None;
tracing::warn!(
session = %state.session_id,
"mild loop stall detected (3x average interval)"
);
self.handle_mild_stall(state).await;
}
SessionMsg::LoopHardStall => {
state.loop_hard_timer = None;
state.loop_mild_timer = None; tracing::warn!(
session = %state.session_id,
"hard loop stall detected — forcing clean context restart"
);
self.handle_hard_stall(state).await;
}
SessionMsg::ClearReminder { clearing_id } => {
if clearing_id == state.next_clearing_id {
state.reminder_cleared = true;
tracing::debug!(
session = %state.session_id,
clearing_id,
"reminder cleared by session"
);
}
}
SessionMsg::SetPendingCompactContinuation(text) => {
state.pending_compact_continuation = Some(text);
}
SessionMsg::DrainPendingCompactContinuation(reply) => {
if !reply.is_closed() {
let _ = reply.send(state.pending_compact_continuation.take());
}
}
SessionMsg::WatchdogTimeout => {
state.watchdog_timer = None;
tracing::warn!(
session = %state.session_id,
"watchdog timeout: no activity for 2x idle_timeout, treating as idle"
);
myself.cast(SessionMsg::IdleTimeout)?;
}
SessionMsg::IdleTimeout => {
state.idle_timer = None;
state.idle = true;
if state.reminder_cleared {
tracing::debug!(
session = %state.session_id,
"idle timeout fired but reminder was cleared — skipping injection"
);
} else {
state.next_clearing_id += 1;
let clearing_id = state.next_clearing_id;
let (reminder, vim_mode, pending) = {
let proto = self.app_state.protocol.read().await;
let session = proto.sessions.get(&state.session_id);
let reminder = session.and_then(|s| s.metadata.reminder.clone());
let vim_mode = session.map(|s| s.metadata.vim_mode).unwrap_or(false);
let pending = proto
.pending_replies
.get(&state.session_id)
.cloned()
.unwrap_or_default();
(reminder, vim_mode, pending)
};
tracing::debug!(
session = %state.session_id,
clearing_id,
pending = pending.len(),
has_reminder = reminder.is_some(),
"idle timeout fired"
);
if let Some(ref reminder_text) = reminder {
let wrapped = format!(
"<ouija-status type=\"reminder\" clearing_id=\"{clearing_id}\">{reminder_text}\n\nIf you have completed all pending work, run: ouija clear-reminder {clearing_id}</ouija-status>"
);
let _ = crate::tmux::locked_inject(
&self.app_state,
&state.session_id,
&state.pane,
&wrapped,
vim_mode,
)
.await;
} else {
let nudge = format!(
"<ouija-status type=\"idle-check\" clearing_id=\"{clearing_id}\">You appear idle. If you are done, run: ouija clear-reminder {clearing_id} — to confirm completion. If you have pending work, continue — this nudge will not repeat until your next idle period.</ouija-status>"
);
let _ = crate::tmux::locked_inject(
&self.app_state,
&state.session_id,
&state.pane,
&nudge,
vim_mode,
)
.await;
state.reminder_cleared = true;
}
if !pending.is_empty() {
tracing::info!(
session = %state.session_id,
count = pending.len(),
"reminding about unanswered pending replies"
);
for p in &pending {
let msg = format!(
"<ouija-status type=\"reminder\" clearing_id=\"{clearing_id}\">Pending reply owed: msg #{} from {}</ouija-status>",
p.msg_id, p.from
);
let _ = crate::tmux::locked_inject(
&self.app_state,
&state.session_id,
&state.pane,
&msg,
vim_mode,
)
.await;
}
}
}
}
}
Ok(())
}
async fn post_stop(
&self,
_myself: ActorRef<Self::Msg>,
state: &mut Self::State,
) -> Result<(), ActorProcessingErr> {
tracing::info!("session agent stopped: {}", state.session_id);
Ok(())
}
}
impl SessionAgent {
async fn send_reminders(&self, entries: &[&PendingReplyEntry], state: &SessionAgentState) {
let vim_mode = self
.app_state
.protocol
.read()
.await
.sessions
.get(&state.session_id)
.map(|s| s.metadata.vim_mode)
.unwrap_or(false);
for p in entries {
let reminder = format!(
"<ouija-status type=\"reminder\">You have an unanswered question from {} (msg {}) — reply using: ouija reply {} {} \"your answer\"</ouija-status>",
p.from, p.msg_id, p.from, p.msg_id
);
let _ = crate::tmux::locked_inject(
&self.app_state,
&state.session_id,
&state.pane,
&reminder,
vim_mode,
)
.await;
}
}
async fn handle_mild_stall(&self, state: &SessionAgentState) {
let (reminder, vim_mode, pending) = {
let proto = self.app_state.protocol.read().await;
let session = proto.sessions.get(&state.session_id);
let reminder = session.and_then(|s| s.metadata.reminder.clone());
let vim_mode = session.map(|s| s.metadata.vim_mode).unwrap_or(false);
let pending = proto
.pending_replies
.get(&state.session_id)
.cloned()
.unwrap_or_default();
(reminder, vim_mode, pending)
};
if let Some(ref reminder_text) = reminder {
let msg = format!(
"<ouija-status type=\"loop-stall\">Loop stall detected (3x average interval). Reminder: {reminder_text}</ouija-status>"
);
let _ = crate::tmux::locked_inject(
&self.app_state,
&state.session_id,
&state.pane,
&msg,
vim_mode,
)
.await;
}
for p in &pending {
let origin_info = {
let proto = self.app_state.protocol.read().await;
proto
.sessions
.get(&p.from)
.and_then(|s| s.pane.clone().map(|pane| (pane, s.metadata.vim_mode)))
};
if let Some((origin_pane, origin_vim)) = origin_info {
let notify_msg = format!(
"<ouija-status type=\"loop-stall\">session '{}' appears stalled (no progress for 3x its average interval)</ouija-status>",
state.session_id
);
let _ = crate::tmux::locked_inject(
&self.app_state,
&p.from,
&origin_pane,
¬ify_msg,
origin_vim,
)
.await;
}
}
}
async fn handle_hard_stall(&self, state: &SessionAgentState) {
let meta = {
let proto = self.app_state.protocol.read().await;
proto
.sessions
.get(&state.session_id)
.map(|s| s.metadata.clone())
};
let Some(meta) = meta else {
return;
};
let Some(ref prompt) = meta.prompt else {
return;
};
let prompt = prompt.clone();
let reminder = meta.reminder.clone();
let app_state = self.app_state.clone();
let sid = state.session_id.clone();
let stash = meta.clone();
tokio::spawn(async move {
crate::nostr_transport::restart_session(
&app_state,
&sid,
true,
Some(prompt.as_str()),
None,
None,
None,
None,
reminder.as_deref(),
)
.await;
{
let mut proto = app_state.protocol.write().await;
if let Some(session) = proto.sessions.get_mut(&sid) {
session.metadata.inherit_recurrence_from(&stash);
}
}
let proto = app_state.protocol.read().await;
app_state.persist_protocol_state(&proto);
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use ractor::Actor;
#[test]
fn agent_state_starts_not_idle() {
let state = SessionAgentState::new("test-sess".into(), "%1".into());
assert!(!state.idle);
}
#[tokio::test]
async fn agent_becomes_idle_after_stopped() {
let state = crate::state::AppState::new_for_test();
let agent = SessionAgent {
app_state: state.clone(),
};
let args = SessionAgentArgs {
session_id: "test-idle".into(),
pane: "%99".into(),
};
state.settings.write().await.idle_timeout_secs = 1;
let (actor, handle) = Actor::spawn(None, agent, args).await.expect("spawn failed");
actor.cast(SessionMsg::Stopped).expect("send failed");
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
assert!(!handle.is_finished());
actor.stop(None);
handle.await.expect("actor failed");
}
#[tokio::test]
async fn agent_active_cancels_idle() {
let state = crate::state::AppState::new_for_test();
let agent = SessionAgent {
app_state: state.clone(),
};
let args = SessionAgentArgs {
session_id: "test-active".into(),
pane: "%99".into(),
};
state.settings.write().await.idle_timeout_secs = 1;
let (actor, handle) = Actor::spawn(None, agent, args).await.expect("spawn failed");
actor.cast(SessionMsg::Stopped).expect("send");
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
actor.cast(SessionMsg::Active).expect("send");
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
assert!(!handle.is_finished());
actor.stop(None);
handle.await.expect("actor failed");
}
#[test]
fn session_metadata_recurrence_fields_default() {
let meta = crate::state::SessionMetadata::default();
assert!(meta.reminder.is_none());
assert!(meta.prompt.is_none());
assert_eq!(meta.iteration, 0);
assert!(meta.iteration_log.is_empty());
assert!(meta.last_iteration_at.is_none());
}
#[test]
fn compute_average_interval_needs_3_entries() {
let log: Vec<crate::daemon_protocol::IterationLogEntry> = vec![
crate::daemon_protocol::IterationLogEntry {
iteration: 1,
message: None,
timestamp: 100,
},
crate::daemon_protocol::IterationLogEntry {
iteration: 2,
message: None,
timestamp: 200,
},
];
assert!(compute_average_loop_interval(&log).is_none());
}
#[test]
fn compute_average_interval_with_3_entries() {
let log = vec![
crate::daemon_protocol::IterationLogEntry {
iteration: 1,
message: None,
timestamp: 100,
},
crate::daemon_protocol::IterationLogEntry {
iteration: 2,
message: None,
timestamp: 200,
},
crate::daemon_protocol::IterationLogEntry {
iteration: 3,
message: None,
timestamp: 400,
},
];
assert_eq!(compute_average_loop_interval(&log), Some(150));
}
#[test]
fn compute_average_interval_empty() {
let log: Vec<crate::daemon_protocol::IterationLogEntry> = vec![];
assert!(compute_average_loop_interval(&log).is_none());
}
#[tokio::test]
async fn agent_injects_reminder_on_idle_without_pending_replies() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "test-reminder".into(),
pane: Some("%99".into()),
metadata: crate::daemon_protocol::SessionMeta {
reminder: Some("call loop_next when done".into()),
..Default::default()
},
})
.await;
let agent = SessionAgent {
app_state: state.clone(),
};
let args = SessionAgentArgs {
session_id: "test-reminder".into(),
pane: "%99".into(),
};
state.settings.write().await.idle_timeout_secs = 1;
let (actor, handle) = Actor::spawn(None, agent, args).await.expect("spawn failed");
actor.cast(SessionMsg::Stopped).expect("send");
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
assert!(!handle.is_finished());
actor.stop(None);
handle.await.expect("actor failed");
}
#[test]
fn agent_state_starts_reminder_not_cleared() {
let state = SessionAgentState::new("test-sess".into(), "%1".into());
assert!(!state.reminder_cleared);
assert_eq!(state.next_clearing_id, 0);
}
#[tokio::test]
async fn active_resets_reminder_cleared() {
let state = crate::state::AppState::new_for_test();
let agent = SessionAgent {
app_state: state.clone(),
};
let args = SessionAgentArgs {
session_id: "test-clear".into(),
pane: "%99".into(),
};
state.settings.write().await.idle_timeout_secs = 60;
let (actor, handle) = Actor::spawn(None, agent, args).await.expect("spawn failed");
actor
.cast(SessionMsg::ClearReminder { clearing_id: 1 })
.expect("send");
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
actor.cast(SessionMsg::Active).expect("send");
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(!handle.is_finished());
actor.stop(None);
handle.await.expect("actor failed");
}
#[tokio::test]
async fn clear_reminder_wrong_id_ignored() {
let state = crate::state::AppState::new_for_test();
let agent = SessionAgent {
app_state: state.clone(),
};
let args = SessionAgentArgs {
session_id: "test-wrong-id".into(),
pane: "%99".into(),
};
state.settings.write().await.idle_timeout_secs = 60;
let (actor, handle) = Actor::spawn(None, agent, args).await.expect("spawn failed");
actor
.cast(SessionMsg::ClearReminder { clearing_id: 999 })
.expect("send");
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(!handle.is_finished());
actor.stop(None);
handle.await.expect("actor failed");
}
}