Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions crates/aionui-ai-agent/src/active_lease.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
use aionui_common::{TimestampMs, now_ms};
use dashmap::DashMap;

pub const ACTIVE_LEASE_TTL_MS: TimestampMs = 90_000;

#[derive(Debug)]
pub struct ActiveLeaseRegistry {
leases: DashMap<String, TimestampMs>,
ttl_ms: TimestampMs,
}

impl ActiveLeaseRegistry {
pub fn new() -> Self {
Self::with_ttl_ms(ACTIVE_LEASE_TTL_MS)
}

pub fn with_ttl_ms(ttl_ms: TimestampMs) -> Self {
Self {
leases: DashMap::new(),
ttl_ms,
}
}

pub fn renew(&self, conversation_id: &str) -> TimestampMs {
let expires_at = now_ms().saturating_add(self.ttl_ms);
self.leases.insert(conversation_id.to_owned(), expires_at);
expires_at
}

pub fn renew_many<'a>(&self, conversation_ids: impl IntoIterator<Item = &'a str>) -> (usize, TimestampMs) {
let expires_at = now_ms().saturating_add(self.ttl_ms);
let mut count = 0;
for conversation_id in conversation_ids {
self.leases.insert(conversation_id.to_owned(), expires_at);
count += 1;
}
(count, expires_at)
}

pub fn active_until(&self, conversation_id: &str) -> Option<TimestampMs> {
let expires_at = *self.leases.get(conversation_id)?;
if expires_at > now_ms() {
Some(expires_at)
} else {
self.leases.remove(conversation_id);
None
}
}

pub fn is_active(&self, conversation_id: &str) -> bool {
self.active_until(conversation_id).is_some()
}
}

impl Default for ActiveLeaseRegistry {
fn default() -> Self {
Self::new()
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;

#[test]
fn renew_sets_active_lease() {
let registry = ActiveLeaseRegistry::new();

let expires_at = registry.renew("conv-1");

assert_eq!(registry.active_until("conv-1"), Some(expires_at));
assert!(registry.is_active("conv-1"));
}

#[test]
fn renew_many_sets_all_leases_and_returns_count() {
let registry = ActiveLeaseRegistry::new();

let (count, expires_at) = registry.renew_many(["conv-1", "conv-2"]);

assert_eq!(count, 2);
assert_eq!(registry.active_until("conv-1"), Some(expires_at));
assert_eq!(registry.active_until("conv-2"), Some(expires_at));
}

#[test]
fn active_lookup_returns_none_for_missing_lease() {
let registry = ActiveLeaseRegistry::new();

assert_eq!(registry.active_until("missing"), None);
assert!(!registry.is_active("missing"));
}

#[test]
fn active_lookup_lazily_removes_expired_lease() {
let registry = ActiveLeaseRegistry::with_ttl_ms(1);
registry.renew("conv-1");

std::thread::sleep(Duration::from_millis(5));

assert_eq!(registry.active_until("conv-1"), None);
assert!(!registry.leases.contains_key("conv-1"));
}
}
5 changes: 4 additions & 1 deletion crates/aionui-ai-agent/src/agent_task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,10 @@ impl AgentInstance {
Self::Acp(m) => m.kill_and_wait(reason),
Self::Aionrs(m) => m.kill_and_wait(reason),
#[cfg(any(test, feature = "test-support"))]
Self::Mock(_) => Box::pin(std::future::ready(())),
Self::Mock(m) => {
let _ = m.kill(reason);
Box::pin(std::future::ready(()))
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion crates/aionui-ai-agent/src/factory/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ pub(super) async fn build(
arc.set_session_id(sid).await;
}

// Open the ACP session eagerly so `POST /warmup` returns only after
// Open the ACP session eagerly so runtime preparation returns only after
// session/new (or claude-meta-resume / session/load) and the first
// reconcile pass have completed. Matches aionrs factory behaviour:
// the caller sees "warmed up" == "ready for PUT /mode | /model".
Expand Down
4 changes: 2 additions & 2 deletions crates/aionui-ai-agent/src/idle_scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const SCAN_INTERVAL_SECS: u64 = 60;
/// Start the background idle agent scanner.
///
/// Periodically scans active tasks and kills ACP agents that have been
/// idle (finished + no activity) beyond the configured threshold.
/// idle (finished or warmup-only + no activity) beyond the configured threshold.
///
/// The scanner runs until the provided `shutdown` signal resolves.
pub fn start_idle_scanner(
Expand Down Expand Up @@ -59,7 +59,7 @@ fn scan_and_cleanup(manager: &Arc<dyn IWorkerTaskManager>, threshold_ms: i64) {
let idle_ids = manager.collect_idle(threshold_ms);

if idle_ids.is_empty() {
debug!(active = manager.active_count(), "Idle scan: no idle agents found");
debug!(active_count = manager.active_count(), "Idle scan: no idle agents found");
return;
}

Expand Down
2 changes: 2 additions & 0 deletions crates/aionui-ai-agent/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#![warn(clippy::disallowed_types)]

//! AI agent lifecycle, worker task dispatch, and skill management.
pub mod active_lease;
pub(crate) mod agent_runtime;
pub mod agent_task;
pub mod capability;
Expand All @@ -21,6 +22,7 @@ pub mod shared_kernel;
pub mod task_manager;
pub mod types;

pub use active_lease::{ACTIVE_LEASE_TTL_MS, ActiveLeaseRegistry};
pub use agent_runtime::AgentRuntime;
#[cfg(any(test, feature = "test-support"))]
pub use agent_task::IMockAgent;
Expand Down
2 changes: 1 addition & 1 deletion crates/aionui-ai-agent/src/manager/acp/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -970,7 +970,7 @@ impl AcpAgentManager {
}

/// Pre-open the ACP session without sending a prompt. Called by the
/// factory after `AcpAgentManager::build` so `POST /warmup` returns
/// factory after `AcpAgentManager::build` so runtime preparation returns
/// only after the session is ready to accept `set_mode` / `set_model`
/// / `prompt`. Idempotent — if already opened, returns immediately.
#[tracing::instrument(skip_all, fields(conversation_id = %self.params.conversation_id))]
Expand Down
Loading
Loading