use std::collections::HashMap;
use std::future::Future;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use parking_lot::Mutex;
use tokio::task::JoinHandle;
use ai_agents_core::{AgentError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RuntimeTaskPurpose {
MainResponse,
StateTransition,
SkillRouting,
ReasoningJudge,
PostTurnFacts,
PostTurnRelationship,
PostTurnSessionMaintenance,
PostTurnCompression,
OrchestrationVoteExtraction,
ObservabilityExport,
}
#[derive(Debug, Clone, Eq)]
pub struct MaintenanceSequenceKey {
pub agent_id: String,
pub scope_id: String,
pub task_kind: RuntimeTaskPurpose,
}
impl MaintenanceSequenceKey {
pub fn actor(
agent_id: impl Into<String>,
actor_id: impl Into<String>,
task_kind: RuntimeTaskPurpose,
) -> Self {
Self {
agent_id: agent_id.into(),
scope_id: actor_id.into(),
task_kind,
}
}
}
impl PartialEq for MaintenanceSequenceKey {
fn eq(&self, other: &Self) -> bool {
self.agent_id == other.agent_id
&& self.scope_id == other.scope_id
&& self.task_kind == other.task_kind
}
}
impl Hash for MaintenanceSequenceKey {
fn hash<H: Hasher>(&self, state: &mut H) {
self.agent_id.hash(state);
self.scope_id.hash(state);
self.task_kind.hash(state);
}
}
struct TrackedTask {
key: Option<MaintenanceSequenceKey>,
handle: JoinHandle<Result<()>>,
}
pub struct BackgroundMaintenanceQueue {
max_tasks: usize,
tasks: Mutex<Vec<TrackedTask>>,
locks: Mutex<HashMap<MaintenanceSequenceKey, Arc<tokio::sync::Mutex<()>>>>,
}
impl BackgroundMaintenanceQueue {
pub fn new(max_tasks: usize) -> Self {
Self {
max_tasks: max_tasks.max(1),
tasks: Mutex::new(Vec::new()),
locks: Mutex::new(HashMap::new()),
}
}
pub fn len(&self) -> usize {
self.tasks.lock().len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn is_full(&self) -> bool {
self.unfinished_count() >= self.max_tasks
}
pub fn spawn<F>(&self, key: Option<MaintenanceSequenceKey>, future: F) -> Result<()>
where
F: Future<Output = Result<()>> + Send + 'static,
{
let mut tasks = self.tasks.lock();
if tasks
.iter()
.filter(|task| !task.handle.is_finished())
.count()
>= self.max_tasks
{
return Err(AgentError::Other(format!(
"background maintenance queue is full (limit {})",
self.max_tasks
)));
}
let lock = key.as_ref().map(|key| {
let mut locks = self.locks.lock();
locks
.entry(key.clone())
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
});
let handle = tokio::spawn(async move {
if let Some(lock) = lock {
let _guard = lock.lock().await;
future.await
} else {
future.await
}
});
tasks.push(TrackedTask { key, handle });
Ok(())
}
pub async fn flush_all(&self) -> Result<()> {
let tasks = std::mem::take(&mut *self.tasks.lock());
for task in tasks {
match task.handle.await {
Ok(Ok(())) => {}
Ok(Err(error)) => return Err(error),
Err(error) => {
return Err(AgentError::Other(format!(
"background maintenance task failed to join: {}",
error
)));
}
}
}
Ok(())
}
pub async fn flush_scope(&self, scope_id: &str) -> Result<()> {
self.flush_matching(|key| key.scope_id == scope_id).await
}
pub async fn flush_purpose(&self, purpose: RuntimeTaskPurpose) -> Result<()> {
self.flush_matching(|key| key.task_kind == purpose).await
}
pub async fn flush_scope_purpose(
&self,
scope_id: &str,
purpose: RuntimeTaskPurpose,
) -> Result<()> {
self.flush_matching(|key| key.scope_id == scope_id && key.task_kind == purpose)
.await
}
async fn flush_matching(
&self,
matches_key: impl Fn(&MaintenanceSequenceKey) -> bool,
) -> Result<()> {
let (matching, remaining): (Vec<_>, Vec<_>) = std::mem::take(&mut *self.tasks.lock())
.into_iter()
.partition(|task| task.key.as_ref().map(&matches_key).unwrap_or(false));
*self.tasks.lock() = remaining;
for task in matching {
match task.handle.await {
Ok(Ok(())) => {}
Ok(Err(error)) => return Err(error),
Err(error) => {
return Err(AgentError::Other(format!(
"background maintenance task failed to join: {}",
error
)));
}
}
}
Ok(())
}
fn unfinished_count(&self) -> usize {
self.tasks
.lock()
.iter()
.filter(|task| !task.handle.is_finished())
.count()
}
}
impl Default for BackgroundMaintenanceQueue {
fn default() -> Self {
Self::new(16)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn finished_task_error_surfaces_on_flush_after_capacity_check() {
let queue = BackgroundMaintenanceQueue::new(1);
queue
.spawn(None, async {
Err(AgentError::Other("background failed".to_string()))
})
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
assert!(!queue.is_full());
let error = queue.flush_all().await.unwrap_err();
assert!(error.to_string().contains("background failed"));
}
#[tokio::test]
async fn flush_scope_purpose_keeps_unmatched_tasks() {
let queue = BackgroundMaintenanceQueue::new(2);
let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
queue
.spawn(
Some(MaintenanceSequenceKey::actor(
"agent",
"actor",
RuntimeTaskPurpose::PostTurnFacts,
)),
async { Ok(()) },
)
.unwrap();
queue
.spawn(
Some(MaintenanceSequenceKey::actor(
"agent",
"actor",
RuntimeTaskPurpose::PostTurnRelationship,
)),
async move {
let _ = release_rx.await;
Ok(())
},
)
.unwrap();
queue
.flush_scope_purpose("actor", RuntimeTaskPurpose::PostTurnFacts)
.await
.unwrap();
assert_eq!(queue.len(), 1);
let _ = release_tx.send(());
queue.flush_all().await.unwrap();
assert!(queue.is_empty());
}
}