use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[cfg(unix)]
use interprocess::local_socket::GenericFilePath;
#[cfg(windows)]
use interprocess::local_socket::GenericNamespaced;
use interprocess::local_socket::tokio::prelude::*;
use interprocess::local_socket::{ListenerOptions, Name};
use objectiveai_sdk::cli::command::agents::instances::list::ResponseItem;
use objectiveai_sdk::cli::websocket_agents_instances_list_listener::{AgentEvent, AgentStatus};
use objectiveai_sdk::cli::websocket_agents_instances_listener::AgentRecord;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::{Mutex, broadcast};
use crate::websockets::mcp_listener::socks_dir;
#[derive(serde::Serialize, serde::Deserialize)]
struct ActiveAnnounce {
agent_instance_hierarchy: String,
}
#[derive(Debug, Clone)]
pub(crate) enum StatusChange {
Activated { agent_instance_hierarchy: String },
Deactivated {
agent_instance_hierarchy: String,
last_active_at: Option<String>,
},
TagsChanged { agent_instance_hierarchy: String },
}
#[cfg(unix)]
fn socket_name(state_dir: &Path) -> std::io::Result<Name<'static>> {
socks_dir(state_dir)
.join("agents.sock")
.to_fs_name::<GenericFilePath>()
}
#[cfg(windows)]
fn socket_name(state_dir: &Path) -> std::io::Result<Name<'static>> {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
state_dir.file_name().hash(&mut hasher);
let state = hasher.finish();
format!("objectiveai-{state:016x}-agents.sock").to_ns_name::<GenericNamespaced>()
}
pub fn bind_agents_socket_listener(
state_dir: &Path,
) -> std::io::Result<interprocess::local_socket::tokio::Listener> {
let _ = std::fs::create_dir_all(socks_dir(state_dir));
let name = socket_name(state_dir)?;
ListenerOptions::new()
.name(name)
.try_overwrite(true)
.create_tokio()
}
#[derive(Clone)]
pub(crate) struct ActiveAgents {
active: Arc<Mutex<HashSet<String>>>,
events: broadcast::Sender<StatusChange>,
state_dir: PathBuf,
ctx: crate::context::Context,
}
impl ActiveAgents {
pub(crate) fn new(
state_dir: PathBuf,
events: broadcast::Sender<StatusChange>,
ctx: crate::context::Context,
) -> Self {
Self {
active: Arc::new(Mutex::new(HashSet::new())),
events,
state_dir,
ctx,
}
}
pub(crate) fn subscribe(&self) -> broadcast::Receiver<StatusChange> {
self.events.subscribe()
}
fn emit(&self, change: StatusChange) {
let _ = self.events.send(change);
}
pub(crate) async fn activate(&self, aih: String) {
{
let mut active = self.active.lock().await;
if !active.insert(aih.clone()) {
return;
}
}
self.emit(StatusChange::Activated {
agent_instance_hierarchy: aih.clone(),
});
let this = self.clone();
tokio::spawn(async move { this.watch(aih).await });
}
async fn watch(self, aih: String) {
let (dir, key) =
crate::command::agents::locks::agent_instance_lock(&self.state_dir, &aih);
loop {
let _ = objectiveai_sdk::lockfile::wait_released(&dir, &key).await;
let mut active = self.active.lock().await;
if objectiveai_sdk::lockfile::try_held(&dir, &key).await {
drop(active);
continue;
}
active.remove(&aih);
drop(active);
let last =
crate::db::time::unix_to_rfc3339(chrono::Utc::now().timestamp());
self.emit(StatusChange::Deactivated {
agent_instance_hierarchy: aih,
last_active_at: Some(last),
});
break;
}
}
async fn snapshot(&self) -> Vec<AgentStatus> {
let items = match self.ctx.db_client().await {
Ok(pool) => crate::db::instances::list_all(pool).await.unwrap_or_default(),
Err(_) => Vec::new(),
};
let active = self.active.lock().await;
let mut out = Vec::with_capacity(items.len());
let mut seen: HashSet<&str> = HashSet::new();
for item in &items {
seen.insert(item.agent_instance_hierarchy.as_str());
out.push(AgentStatus {
agent_instance_hierarchy: item.agent_instance_hierarchy.clone(),
active: active.contains(&item.agent_instance_hierarchy),
});
}
for aih in active.iter() {
if !seen.contains(aih.as_str()) {
out.push(AgentStatus {
agent_instance_hierarchy: aih.clone(),
active: true,
});
}
}
out
}
pub(crate) async fn build_record_for(&self, aih: &str) -> Option<AgentRecord> {
let active = self.active.lock().await.contains(aih);
let pool = self.ctx.db_client().await.ok()?;
let item = crate::db::instances::get_exact(pool, aih).await.ok()?;
Some(record_from_item(&item, active))
}
pub(crate) async fn watch_tag_changes(self) {
use std::time::Duration;
loop {
let reconnect = async {
let pool = self.ctx.db_client().await.ok()?;
let mut listener =
sqlx::postgres::PgListener::connect_with(&**pool).await.ok()?;
listener.listen("tags_changed").await.ok()?;
Some(listener)
}
.await;
let Some(mut listener) = reconnect else {
tokio::time::sleep(Duration::from_secs(1)).await;
continue;
};
while let Ok(notification) = listener.recv().await {
self.emit(StatusChange::TagsChanged {
agent_instance_hierarchy: notification.payload().to_string(),
});
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
pub(crate) async fn reconcile_startup(&self) {
let Ok(pool) = self.ctx.db_client().await else {
return;
};
let Ok(items) = crate::db::instances::list_all(pool).await else {
return;
};
for item in items {
let (dir, key) = crate::command::agents::locks::agent_instance_lock(
&self.state_dir,
&item.agent_instance_hierarchy,
);
if objectiveai_sdk::lockfile::try_held(&dir, &key).await {
self.activate(item.agent_instance_hierarchy).await;
}
}
}
}
fn record_from_item(item: &ResponseItem, active: bool) -> AgentRecord {
AgentRecord {
agent_instance_hierarchy: item.agent_instance_hierarchy.clone(),
tags: item.tags.clone(),
queued: item.queued,
logged: item.logged,
active,
spawned_at: item.created_at.clone(),
last_active_at: if active {
None
} else {
item.last_active_at.clone()
},
}
}
pub fn serve_agents_socket_listener(
listener: interprocess::local_socket::tokio::Listener,
active: ActiveAgents,
) {
tokio::spawn(async move {
loop {
let conn = match listener.accept().await {
Ok(conn) => conn,
Err(_) => continue,
};
let active = active.clone();
tokio::spawn(async move {
let (read_half, _write_half) = tokio::io::split(conn);
let mut reader = BufReader::new(read_half);
let mut line = String::new();
if reader.read_line(&mut line).await.is_err() {
return;
}
let trimmed = line.trim();
if trimmed.is_empty() {
return;
}
if let Ok(announce) = serde_json::from_str::<ActiveAnnounce>(trimmed) {
active.activate(announce.agent_instance_hierarchy).await;
}
});
}
});
}
pub async fn announce_active(state_dir: &Path, aih: &str) {
let announce = ActiveAnnounce {
agent_instance_hierarchy: aih.to_string(),
};
let Ok(line) = serde_json::to_string(&announce) else {
return;
};
const ERROR_PIPE_BUSY: i32 = 231;
let mut attempts = 0u32;
let conn = loop {
let Ok(name) = socket_name(state_dir) else {
return;
};
match LocalSocketStream::connect(name).await {
Ok(conn) => break conn,
Err(e) if e.raw_os_error() == Some(ERROR_PIPE_BUSY) && attempts < 20 => {
attempts += 1;
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
Err(_) => return,
}
};
let (_read_half, mut write_half) = tokio::io::split(conn);
let _ = write_half.write_all(line.as_bytes()).await;
let _ = write_half.write_all(b"\n").await;
let _ = write_half.flush().await;
let _ = write_half.shutdown().await;
}
pub(crate) async fn agents_handler(
axum::extract::State(state): axum::extract::State<
crate::websockets::daemon_stream::DaemonWsState,
>,
ws: axum::extract::ws::WebSocketUpgrade,
) -> axum::response::Response {
ws.on_upgrade(move |mut socket| async move {
if !crate::websockets::daemon_auth::authenticate(&mut socket, state.secret.as_ref())
.await
{
return;
}
agents_pump(socket, state.active).await;
})
}
async fn agents_pump(mut socket: axum::extract::ws::WebSocket, active: ActiveAgents) {
use axum::extract::ws::Message;
let mut rx = active.subscribe();
let snapshot = AgentEvent::Snapshot {
agents: active.snapshot().await,
};
if let Ok(frame) = serde_json::to_string(&snapshot) {
if socket.send(Message::Text(frame.into())).await.is_err() {
return;
}
}
loop {
tokio::select! {
received = rx.recv() => match received {
Ok(change) => {
let event = match change {
StatusChange::Activated { agent_instance_hierarchy } => {
AgentEvent::Activated { agent_instance_hierarchy }
}
StatusChange::Deactivated { agent_instance_hierarchy, .. } => {
AgentEvent::Deactivated { agent_instance_hierarchy }
}
StatusChange::TagsChanged { .. } => continue,
};
let Ok(frame) = serde_json::to_string(&event) else {
continue;
};
if socket.send(Message::Text(frame.into())).await.is_err() {
break;
}
}
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => break,
},
inbound = socket.recv() => match inbound {
None | Some(Err(_)) | Some(Ok(Message::Close(_))) => break,
Some(Ok(_)) => {}
},
}
}
}