use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub type AgentId = u64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AgentState {
Active,
Crashed,
Gone,
}
#[derive(Debug, Clone)]
pub struct AgentInfo {
pub id: AgentId,
pub name: String,
pub state: AgentState,
pub last_seen_ms: i64,
pub edit_count: u64,
pub locked_paths: HashMap<String, i64>,
}
#[derive(Debug, Clone, Copy)]
pub struct RegistryConfig {
pub default_lock_ttl_ms: i64,
pub agent_timeout_ms: i64,
}
impl Default for RegistryConfig {
fn default() -> Self {
Self {
default_lock_ttl_ms: 30_000,
agent_timeout_ms: 45_000,
}
}
}
#[derive(Debug, Default, Clone)]
pub struct AgentRegistry {
config: RegistryConfig,
next_id: AgentId,
agents: HashMap<AgentId, AgentInfo>,
}
impl AgentRegistry {
pub fn new() -> Self {
Self::with_config(RegistryConfig::default())
}
pub fn with_config(config: RegistryConfig) -> Self {
Self {
config,
next_id: 1,
agents: HashMap::new(),
}
}
pub fn config(&self) -> RegistryConfig {
self.config
}
pub fn register(&mut self, name: impl Into<String>, now_ms: i64) -> AgentId {
let id = self.next_id;
self.next_id = self
.next_id
.checked_add(1)
.expect("AgentId overflow — registry has been alive an absurd amount of time");
self.register_with_id(id, name, now_ms);
id
}
pub fn register_with_id(
&mut self,
id: AgentId,
name: impl Into<String>,
now_ms: i64,
) -> AgentId {
self.next_id = self.next_id.max(id.saturating_add(1));
self.agents.insert(
id,
AgentInfo {
id,
name: name.into(),
state: AgentState::Active,
last_seen_ms: now_ms,
edit_count: 0,
locked_paths: HashMap::new(),
},
);
id
}
pub fn heartbeat(&mut self, id: AgentId, now_ms: i64) {
match self.agents.get_mut(&id) {
Some(info) => {
info.last_seen_ms = now_ms;
if info.state == AgentState::Crashed {
info.state = AgentState::Active;
}
}
None => {
self.register_with_id(id, format!("agent-{id}"), now_ms);
}
}
}
pub fn unregister(&mut self, id: AgentId) {
self.agents.remove(&id);
}
pub fn agents(&self) -> impl Iterator<Item = &AgentInfo> {
self.agents.values()
}
pub fn get(&self, id: AgentId) -> Option<&AgentInfo> {
self.agents.get(&id)
}
pub fn note_edit(&mut self, id: AgentId, now_ms: i64) {
if let Some(info) = self.agents.get_mut(&id) {
info.edit_count = info.edit_count.saturating_add(1);
info.last_seen_ms = now_ms;
}
}
pub fn try_lock(
&mut self,
agent_id: AgentId,
path: &str,
ttl_ms: Option<i64>,
now_ms: i64,
) -> bool {
self.reap(now_ms);
let ttl = ttl_ms.unwrap_or(self.config.default_lock_ttl_ms);
for (other_id, other) in &self.agents {
if *other_id == agent_id {
continue;
}
if let Some(expiry) = other.locked_paths.get(path) {
if *expiry > now_ms {
return false;
}
}
}
if !self.agents.contains_key(&agent_id) {
self.register_with_id(agent_id, format!("agent-{agent_id}"), now_ms);
}
let info = self
.agents
.get_mut(&agent_id)
.expect("just registered above");
info.locked_paths.insert(path.to_string(), now_ms + ttl);
info.last_seen_ms = now_ms;
true
}
pub fn release_lock(&mut self, agent_id: AgentId, path: &str) {
if let Some(info) = self.agents.get_mut(&agent_id) {
info.locked_paths.remove(path);
}
}
pub fn lock_holder(&mut self, path: &str, now_ms: i64) -> Option<AgentId> {
self.reap(now_ms);
for (id, info) in &self.agents {
if let Some(expiry) = info.locked_paths.get(path) {
if *expiry > now_ms {
return Some(*id);
}
}
}
None
}
pub fn reap(&mut self, now_ms: i64) {
let timeout = self.config.agent_timeout_ms;
for info in self.agents.values_mut() {
if info.state == AgentState::Active && now_ms - info.last_seen_ms > timeout {
info.state = AgentState::Crashed;
info.locked_paths.clear();
}
}
}
pub fn snapshot(&self) -> SerializedRegistry {
SerializedRegistry {
next_id: self.next_id,
agents: self.agents.values().map(SerializedAgent::from).collect(),
}
}
pub fn from_snapshot(config: RegistryConfig, snap: SerializedRegistry) -> Self {
let mut agents = HashMap::with_capacity(snap.agents.len());
for entry in snap.agents {
agents.insert(
entry.id,
AgentInfo {
id: entry.id,
name: entry.name,
state: entry.state,
last_seen_ms: entry.last_seen_ms,
edit_count: entry.edit_count,
locked_paths: entry.locked_paths,
},
);
}
Self {
config,
next_id: snap.next_id.max(1),
agents,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializedAgent {
pub id: AgentId,
pub name: String,
pub state: AgentState,
pub last_seen_ms: i64,
pub edit_count: u64,
pub locked_paths: HashMap<String, i64>,
}
impl From<&AgentInfo> for SerializedAgent {
fn from(info: &AgentInfo) -> Self {
Self {
id: info.id,
name: info.name.clone(),
state: info.state,
last_seen_ms: info.last_seen_ms,
edit_count: info.edit_count,
locked_paths: info.locked_paths.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SerializedRegistry {
#[serde(default)]
pub next_id: AgentId,
#[serde(default)]
pub agents: Vec<SerializedAgent>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn register_then_heartbeat_keeps_agent_active() {
let mut reg = AgentRegistry::new();
let id = reg.register("editor", 1_000);
assert!(matches!(reg.get(id).unwrap().state, AgentState::Active));
reg.heartbeat(id, 5_000);
let info = reg.get(id).unwrap();
assert_eq!(info.last_seen_ms, 5_000);
assert_eq!(info.state, AgentState::Active);
}
#[test]
fn reap_marks_silent_agents_crashed_and_drops_locks() {
let mut reg = AgentRegistry::new();
let id = reg.register("editor", 0);
assert!(reg.try_lock(id, "src/main.rs", None, 0));
reg.reap(60_000);
let info = reg.get(id).unwrap();
assert_eq!(info.state, AgentState::Crashed);
assert!(info.locked_paths.is_empty());
assert_eq!(reg.lock_holder("src/main.rs", 60_000), None);
}
#[test]
fn try_lock_blocks_other_agents_until_expiry() {
let mut reg = AgentRegistry::new();
let a = reg.register("a", 0);
let b = reg.register("b", 0);
assert!(reg.try_lock(a, "f.rs", Some(1_000), 0));
assert!(!reg.try_lock(b, "f.rs", Some(1_000), 100));
assert!(reg.try_lock(b, "f.rs", Some(1_000), 5_000));
assert_eq!(reg.lock_holder("f.rs", 5_000), Some(b));
}
#[test]
fn release_lock_lets_others_acquire_immediately() {
let mut reg = AgentRegistry::new();
let a = reg.register("a", 0);
let b = reg.register("b", 0);
reg.try_lock(a, "x", None, 0);
reg.release_lock(a, "x");
assert!(reg.try_lock(b, "x", None, 100));
}
#[test]
fn heartbeat_resurrects_a_crashed_agent() {
let mut reg = AgentRegistry::new();
let id = reg.register("a", 0);
reg.reap(60_000);
assert_eq!(reg.get(id).unwrap().state, AgentState::Crashed);
reg.heartbeat(id, 70_000);
assert_eq!(reg.get(id).unwrap().state, AgentState::Active);
}
#[test]
fn snapshot_round_trips_through_serialized_form() {
let mut reg = AgentRegistry::new();
let id = reg.register("editor", 100);
reg.try_lock(id, "src/main.rs", Some(1_000), 100);
let snap = reg.snapshot();
let restored = AgentRegistry::from_snapshot(reg.config(), snap);
assert_eq!(restored.get(id).unwrap().name, "editor");
assert_eq!(
restored
.get(id)
.unwrap()
.locked_paths
.get("src/main.rs")
.copied(),
Some(1_100)
);
}
}