use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
use chrono::{DateTime, Utc};
use crate::message_router::{AgentJob, JobKind};
use crate::registry::AGENT_REGISTRY;
use crate::session::SessionContext;
use crate::session::TRANSIENT_AGENT_ID_PREFIXES;
use crate::{ChatRole, Role};
const POLL_INTERVAL: Duration = Duration::from_mins(5);
const MAX_RETRIES: u8 = 10;
const BASE_BACKOFF_MINUTES: i64 = 10;
const MAX_BACKOFF_MINUTES: i64 = 40;
const STALE_GRACE_PERIOD: Duration = Duration::from_mins(5);
struct RetryState {
attempt_count: u8,
last_attempt_at: DateTime<Utc>,
backoff_minutes: i64,
}
pub(crate) struct DeadSessionTracker {
inner: Mutex<HashMap<String, RetryState>>,
}
impl DeadSessionTracker {
pub(crate) fn new() -> Self {
Self {
inner: Mutex::new(HashMap::new()),
}
}
fn should_retry(&self, agent_id: &str) -> bool {
let map = self.inner.lock().expect("DeadSessionTracker lock poisoned");
match map.get(agent_id) {
Some(state) => {
if state.attempt_count >= MAX_RETRIES {
return false;
}
let elapsed = (Utc::now() - state.last_attempt_at).num_minutes();
elapsed >= state.backoff_minutes
}
None => true,
}
}
fn record_attempt(&self, agent_id: &str) {
let mut map = self.inner.lock().expect("DeadSessionTracker lock poisoned");
let state = map.entry(agent_id.to_string()).or_insert(RetryState {
attempt_count: 0,
last_attempt_at: Utc::now(),
backoff_minutes: BASE_BACKOFF_MINUTES,
});
state.attempt_count += 1;
state.last_attempt_at = Utc::now();
state.backoff_minutes = (state.backoff_minutes * 2).min(MAX_BACKOFF_MINUTES);
}
fn cleanup(&self, agent_id: &str) {
self.inner
.lock()
.expect("DeadSessionTracker lock poisoned")
.remove(agent_id);
}
#[cfg(test)]
fn has_exhausted_retries(&self, agent_id: &str) -> bool {
let map = self.inner.lock().expect("DeadSessionTracker lock poisoned");
map.get(agent_id)
.is_some_and(|s| s.attempt_count >= MAX_RETRIES)
}
}
pub(crate) static DEAD_SESSION_TRACKER: std::sync::LazyLock<DeadSessionTracker> =
std::sync::LazyLock::new(DeadSessionTracker::new);
pub async fn run_dead_session_recovery_loop() {
loop {
if !crate::shutdown::sleep_or_shutdown_or_drain(POLL_INTERVAL).await {
break;
}
if let Err(e) = recover_dead_sessions().await {
tracing::warn!(error = %e, "Dead session recovery poller failed");
}
}
}
fn excluded_agent_id_prefixes() -> impl Iterator<Item = &'static str> {
std::iter::once("manager_").chain(TRANSIENT_AGENT_ID_PREFIXES.iter().copied())
}
fn is_recovery_candidate(last_role: ChatRole) -> bool {
matches!(last_role, ChatRole::User | ChatRole::Tool)
}
async fn recover_dead_sessions() -> anyhow::Result<()> {
let now = Utc::now();
let sessions = crate::session::store()
.list_sessions_with_metadata_excluding(&excluded_agent_id_prefixes().collect::<Vec<&str>>())
.await;
for session in &sessions {
let agent_id = &session.agent_id;
let Some(last_role) = crate::session::store()
.get_last_message_role(agent_id)
.await
else {
DEAD_SESSION_TRACKER.cleanup(agent_id);
continue; };
if !is_recovery_candidate(last_role) {
DEAD_SESSION_TRACKER.cleanup(agent_id);
continue;
}
if AGENT_REGISTRY.contains(agent_id) {
continue;
}
let age = now - session.last_activity;
let grace = chrono::Duration::from_std(STALE_GRACE_PERIOD)
.expect("STALE_GRACE_PERIOD fits in chrono::Duration");
if age < grace {
continue;
}
if !DEAD_SESSION_TRACKER.should_retry(agent_id) {
continue;
}
let Some(ctx) = crate::session::store().get_session_context(agent_id).await else {
DEAD_SESSION_TRACKER.cleanup(agent_id);
tracing::warn!(
agent_id = %agent_id,
"Dead session recovery: no context found \
(corrupted data) — skipping permanently"
);
continue;
};
let Ok(role) = ctx.role.parse::<Role>() else {
DEAD_SESSION_TRACKER.cleanup(agent_id);
tracing::warn!(
agent_id = %agent_id,
role = %ctx.role,
"Dead session recovery: invalid role in session context — \
skipping permanently"
);
continue;
};
attempt_recovery(agent_id, &ctx, role);
DEAD_SESSION_TRACKER.record_attempt(agent_id);
}
Ok(())
}
#[cfg(test)]
fn is_excluded_agent_id(agent_id: &str) -> bool {
excluded_agent_id_prefixes().any(|p| agent_id.starts_with(p))
}
fn attempt_recovery(agent_id: &str, ctx: &SessionContext, role: Role) {
let job = AgentJob {
content: String::new(),
workspace_name: ctx.workspace_name.clone(),
user_name: ctx.user_name.clone(),
channel: ctx.channel.clone(),
kind: JobKind::RecoveryRetry,
role,
reply_target: None,
pending_job_id: None,
};
tracing::info!(
agent_id = %agent_id,
role = %role.as_str(),
workspace = %ctx.workspace_name,
user = %ctx.user_name,
channel = %ctx.channel,
"Dead session recovery: routing retry job"
);
crate::message_router::route(agent_id, job);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_excluded_agent_id_transient_prefixes() {
for prefix in excluded_agent_id_prefixes() {
let id = format!("{prefix}suffix");
assert!(
is_excluded_agent_id(&id),
"expected '{id}' (prefix '{prefix}') to be excluded"
);
}
}
#[test]
fn test_is_excluded_agent_id_direct_session() {
assert!(!is_excluded_agent_id("gui_alice_main_workspace_engineer"));
assert!(!is_excluded_agent_id("telegram_bob_my_project_analyst"));
assert!(!is_excluded_agent_id(
"voice_charlie_personal_work_assistant"
));
}
#[test]
fn test_direct_session_underscore_in_names_not_mistaken_for_exclusion() {
assert!(!is_excluded_agent_id(
"telegram_some_user_my_cool_workspace_reviewer"
));
}
#[test]
fn test_is_recovery_candidate_classification() {
assert!(is_recovery_candidate(ChatRole::User));
assert!(is_recovery_candidate(ChatRole::Tool));
assert!(!is_recovery_candidate(ChatRole::Assistant));
assert!(!is_recovery_candidate(ChatRole::System));
}
#[test]
fn test_dead_session_tracker_max_retries() {
let tracker = DeadSessionTracker::new();
let agent_id = "test_agent_engineer";
assert!(tracker.should_retry(agent_id));
tracker.record_attempt(agent_id);
assert!(!tracker.should_retry(agent_id));
for _ in 2..=MAX_RETRIES {
tracker.record_attempt(agent_id);
}
assert!(
!tracker.should_retry(agent_id),
"should be blocked after {MAX_RETRIES} attempts"
);
}
#[test]
fn test_dead_session_tracker_cleanup() {
let tracker = DeadSessionTracker::new();
let agent_id = "test_cleanup_engineer";
tracker.record_attempt(agent_id);
assert!(!tracker.should_retry(agent_id));
tracker.cleanup(agent_id);
assert!(tracker.should_retry(agent_id));
}
#[test]
fn test_dead_session_tracker_exhaustion_is_permanent() {
let tracker = DeadSessionTracker::new();
let agent_id = "test_permanent_engineer";
for _ in 0..MAX_RETRIES {
tracker.record_attempt(agent_id);
}
assert!(!tracker.should_retry(agent_id));
assert!(!tracker.should_retry(agent_id));
assert!(tracker.has_exhausted_retries(agent_id));
}
#[test]
fn test_dead_session_tracker_backoff_and_reset_on_restart() {
let tracker = DeadSessionTracker::new();
let agent_id = "test_backoff_engineer";
assert!(tracker.should_retry(agent_id));
tracker.record_attempt(agent_id);
assert!(!tracker.should_retry(agent_id));
let tracker2 = DeadSessionTracker::new();
assert!(
tracker2.should_retry(agent_id),
"should be fresh after simulated restart"
);
}
}