1use chrono::Utc;
2use std::collections::HashSet;
3use std::path::Path;
4
5#[cfg(test)]
6use super::diary::{AgentDiary, DiaryEntryType};
7use super::persistence::{
8 FileLock, ProcessIdentityIndex, agents_dir, create_agents_dir, generate_short_id,
9 load_registry_file, mutate_persistent, save_registry_file,
10};
11use super::{
12 AgentEntry, AgentRegistry, AgentStatus, LogicalSessionPresence, REQUEST_DIGEST_METADATA_KEY,
13 REQUEST_ID_METADATA_KEY, RoleSource, ScratchpadEntry, TransportClient,
14};
15use crate::core::a2a::message::{MessagePriority, PrivacyLevel};
16
17const PLACEHOLDER_ROLE: &str = "context-engine";
19const WORKER_LEASE_EXPIRED: &str = "worker lease expired; register again before continuing";
20const TRANSPORT_IDLE: &str = "connected; no tool call within the worker lease";
23
24#[derive(Default)]
26struct RegistrationMeta {
27 role_source: Option<RoleSource>,
28 transport: Option<TransportClient>,
29}
30
31fn explicit_role_source(role: Option<&str>) -> Option<RoleSource> {
34 match role {
35 None => None,
36 Some(PLACEHOLDER_ROLE) => Some(RoleSource::Assumed),
37 Some(_) => Some(RoleSource::Declared),
38 }
39}
40
41static RENEWED: std::sync::Mutex<std::collections::BTreeSet<String>> =
44 std::sync::Mutex::new(std::collections::BTreeSet::new());
45
46fn note_renewed(agent_id: &str) {
47 if let Ok(mut renewed) = RENEWED.lock() {
48 renewed.insert(agent_id.to_string());
49 }
50 crate::core::agents::friction::record_for(
51 crate::core::agents::friction::REGISTRATION_RENEWED,
52 Some(agent_id),
53 None,
54 );
55}
56
57pub fn take_renewed(agent_id: &str) -> bool {
60 RENEWED
61 .lock()
62 .is_ok_and(|mut renewed| renewed.remove(agent_id))
63}
64
65const WAIT_POLL: std::time::Duration = std::time::Duration::from_millis(500);
67const WAIT_KEEPALIVE: std::time::Duration = std::time::Duration::from_secs(30);
69const WAIT_RACY_MTIME: std::time::Duration = std::time::Duration::from_secs(3);
73
74const BROADCAST_GRACE: chrono::Duration = chrono::Duration::minutes(10);
78
79fn unread_for(
85 e: &ScratchpadEntry,
86 agent_id: &str,
87 project_root: Option<&str>,
88 now: chrono::DateTime<Utc>,
89 floor: Option<chrono::DateTime<Utc>>,
90) -> bool {
91 !e.read_by.iter().any(|reader| reader == agent_id)
92 && e.from_agent != agent_id
93 && (e.to_agent.as_deref() == Some(agent_id)
94 || (e.to_agent.is_none() && floor.is_none_or(|floor| e.timestamp >= floor)))
95 && project_root.is_none_or(|root| {
96 e.project_root
97 .as_deref()
98 .is_some_and(|own| crate::core::pathutil::same_project(own, root))
99 })
100 && e.expires_at.is_none_or(|exp| exp > now)
101}
102
103fn owned_by_current_process(agent: &AgentEntry) -> bool {
105 let pid = std::process::id();
106 agent.pid == pid
107 && agent.process_identity.is_some()
108 && crate::ipc::process::identity(pid).as_ref() == agent.process_identity.as_ref()
109}
110
111const LOGICAL_SESSION_SOURCE_MAX_BYTES: usize = 64;
112const LOGICAL_SESSION_WORKSPACE_MAX_BYTES: usize = 4096;
113const LOGICAL_SESSION_ID_MAX_BYTES: usize = 256;
114const MAX_RETAINED_FINISHED_AGENTS: usize = 32;
115const DEFAULT_ACTIVE_WORKER_LEASE_SECONDS: i64 = 120;
119
120fn active_worker_lease_seconds() -> i64 {
121 let configured = crate::core::config::Config::load()
122 .agents
123 .active_worker_lease_seconds;
124 i64::try_from(configured)
125 .unwrap_or(DEFAULT_ACTIVE_WORKER_LEASE_SECONDS)
126 .max(1)
127}
128
129fn presence_ttl() -> u64 {
130 crate::core::config::Config::load()
131 .agents
132 .presence_ttl_hours
133}
134
135fn max_scratchpad() -> usize {
136 crate::core::config::Config::load()
137 .agents
138 .max_scratchpad_entries
139}
140
141pub(crate) fn process_identity_matches(
142 agent: &AgentEntry,
143 compatibility_identities: &ProcessIdentityIndex,
144) -> bool {
145 agent
146 .process_identity
147 .as_ref()
148 .or_else(|| compatibility_identities.get(&agent.agent_id))
149 .is_some_and(|identity| crate::ipc::process::matches_identity(agent.pid, identity))
150}
151
152fn safe_legacy_identity(agent: &AgentEntry) -> Option<crate::ipc::process::ProcessIdentity> {
153 if agent.process_identity.is_some() {
154 return None;
155 }
156 let identity = crate::ipc::process::identity(agent.pid)?;
157 legacy_identity_matches_registration(agent, &identity).then_some(identity)
158}
159
160fn legacy_identity_matches_registration(
161 agent: &AgentEntry,
162 identity: &crate::ipc::process::ProcessIdentity,
163) -> bool {
164 if Path::new(&identity.executable)
165 .file_name()
166 .and_then(|name| name.to_str())
167 != Some("lean-ctx")
168 {
169 return false;
170 }
171 let started_at = agent.started_at.timestamp_micros();
172 let Ok(process_started_at) = i64::try_from(identity.start_marker) else {
173 return false;
174 };
175 const REGISTRATION_GRACE_MICROS: i64 = 5 * 60 * 1_000_000;
176 process_started_at <= started_at
177 && started_at.saturating_sub(process_started_at) <= REGISTRATION_GRACE_MICROS
178}
179
180fn is_recoverable_legacy_finished(agent: &AgentEntry) -> bool {
181 agent.process_identity.is_none()
182 && agent.status == AgentStatus::Finished
183 && agent.status_message.as_deref() == Some("process identity no longer matches")
184}
185
186impl AgentRegistry {
187 pub fn new() -> Self {
188 Self {
189 agents: Vec::new(),
190 scratchpad: Vec::new(),
191 logical_sessions: Vec::new(),
192 logical_session_telemetry_seen: false,
193 updated_at: Utc::now(),
194 }
195 }
196
197 pub fn register(
198 &mut self,
199 agent_type: &str,
200 role: Option<&str>,
201 project_root: &str,
202 durable_identity_id: Option<&str>,
203 ) -> Result<String, String> {
204 self.register_process(
205 agent_type,
206 role,
207 project_root,
208 durable_identity_id,
209 std::process::id(),
210 )
211 }
212
213 fn register_process(
214 &mut self,
215 agent_type: &str,
216 role: Option<&str>,
217 project_root: &str,
218 durable_identity_id: Option<&str>,
219 pid: u32,
220 ) -> Result<String, String> {
221 self.register_process_with(
222 agent_type,
223 role,
224 project_root,
225 durable_identity_id,
226 pid,
227 RegistrationMeta {
228 role_source: explicit_role_source(role),
229 transport: None,
230 },
231 )
232 }
233
234 pub fn register_child(
239 &mut self,
240 agent_type: &str,
241 role: &str,
242 project_root: &str,
243 parent_id: &str,
244 ) -> Result<String, String> {
245 let pid = std::process::id();
246 let identity = crate::ipc::process::identity(pid)
247 .ok_or_else(|| format!("cannot establish immutable process identity for PID {pid}"))?;
248 if !self.held_by_this_process(parent_id) {
249 return Err(format!(
250 "agent {parent_id} is not registered by this process"
251 ));
252 }
253 let now = Utc::now();
254 if let Some(child) = self.agents.iter_mut().find(|a| {
255 a.parent.as_deref() == Some(parent_id)
256 && a.role.as_deref() == Some(role)
257 && a.status != AgentStatus::Finished
258 }) {
259 child.last_active = now;
260 child.status = AgentStatus::Active;
261 child.project_root = project_root.to_string();
262 return Ok(child.agent_id.clone());
263 }
264 let agent_id = format!("{}-{}-{}", agent_type, pid, generate_short_id());
265 self.agents.push(AgentEntry {
266 agent_id: agent_id.clone(),
267 agent_type: agent_type.to_string(),
268 role: Some(role.to_string()),
269 project_root: project_root.to_string(),
270 started_at: now,
271 last_active: now,
272 pid,
273 process_identity: Some(identity),
274 durable_identity_id: None,
275 status: AgentStatus::Active,
276 status_message: None,
277 transport: None,
278 role_source: Some(RoleSource::Declared),
279 parent: Some(parent_id.to_string()),
280 });
281 self.updated_at = now;
282 crate::core::events::emit_agent_action(&agent_id, "register", None);
283 Ok(agent_id)
284 }
285
286 fn broadcast_floor(&self, agent_id: &str) -> Option<chrono::DateTime<Utc>> {
289 self.agents
290 .iter()
291 .find(|a| a.agent_id == agent_id)
292 .map(|a| a.started_at - BROADCAST_GRACE)
293 }
294
295 pub fn held_by_this_process(&self, agent_id: &str) -> bool {
298 let pid = std::process::id();
299 let identity = crate::ipc::process::identity(pid);
300 self.agents.iter().any(|a| {
301 a.agent_id == agent_id
302 && a.pid == pid
303 && a.status != AgentStatus::Finished
304 && a.process_identity.is_some()
305 && a.process_identity == identity
306 })
307 }
308
309 pub fn declared_role(&self, agent_id: &str) -> Option<String> {
311 self.agents
312 .iter()
313 .find(|a| a.agent_id == agent_id && a.role_source == Some(RoleSource::Declared))
314 .and_then(|a| a.role.clone())
315 }
316
317 pub fn register_transport(
320 &mut self,
321 role: &str,
322 role_source: RoleSource,
323 project_root: &str,
324 durable_identity_id: Option<&str>,
325 transport: TransportClient,
326 ) -> Result<String, String> {
327 self.register_process_with(
328 "mcp",
329 Some(role),
330 project_root,
331 durable_identity_id,
332 std::process::id(),
333 RegistrationMeta {
334 role_source: Some(role_source),
335 transport: Some(transport),
336 },
337 )
338 }
339
340 fn register_process_with(
341 &mut self,
342 agent_type: &str,
343 role: Option<&str>,
344 project_root: &str,
345 durable_identity_id: Option<&str>,
346 pid: u32,
347 meta: RegistrationMeta,
348 ) -> Result<String, String> {
349 if let Some(identity_id) = durable_identity_id {
350 crate::core::agent_registry::require_active(identity_id)?;
351 }
352 let identity = crate::ipc::process::identity(pid)
353 .ok_or_else(|| format!("cannot establish immutable process identity for PID {pid}"))?;
354 let agent_id = format!("{}-{}-{}", agent_type, pid, generate_short_id());
355
356 if let Some(existing_index) = self.agents.iter().position(|a| {
357 a.pid == pid
358 && a.parent.is_none()
359 && a.status != AgentStatus::Finished
360 && a.process_identity.as_ref() == Some(&identity)
361 }) {
362 let existing_binding = self.agents[existing_index].durable_identity_id.clone();
363 if let (Some(existing_id), Some(requested_id)) =
364 (existing_binding.as_deref(), durable_identity_id)
365 && existing_id != requested_id
366 {
367 return Err(format!(
368 "agent presence '{}' is already bound to durable identity '{}'; refusing rebind to '{}'",
369 self.agents[existing_index].agent_id, existing_id, requested_id
370 ));
371 }
372 if existing_binding.is_some() {
373 self.require_active_for_presence(
374 &self.agents[existing_index].agent_id,
375 "registration",
376 )?;
377 }
378 let existing = &mut self.agents[existing_index];
379 existing.last_active = Utc::now();
380 existing.status = AgentStatus::Active;
381 if existing.status_message.as_deref() == Some(TRANSPORT_IDLE) {
382 existing.status_message = None;
383 }
384 if meta.transport.is_none() || existing.agent_type == "mcp" {
387 existing.agent_type = agent_type.to_string();
388 }
389 existing.project_root = project_root.to_string();
390 match role {
396 Some(PLACEHOLDER_ROLE) if existing.role.is_some() => {}
397 Some(_)
398 if meta.role_source == Some(RoleSource::Assumed)
399 && existing.role_source == Some(RoleSource::Declared) => {}
400 Some(r) => {
401 existing.role = Some(r.to_string());
402 existing.role_source = meta.role_source;
403 }
404 None => {}
405 }
406 if let Some(transport) = meta.transport
408 && (transport.is_identified() || existing.transport.is_none())
409 {
410 existing.transport = Some(transport);
411 }
412 if existing.durable_identity_id.is_none() {
413 existing.durable_identity_id = durable_identity_id.map(str::to_string);
414 }
415 return Ok(existing.agent_id.clone());
416 }
417
418 for existing in self
422 .agents
423 .iter_mut()
424 .filter(|agent| agent.pid == pid && agent.status != AgentStatus::Finished)
425 {
426 existing.status = AgentStatus::Finished;
427 existing.status_message =
428 Some("superseded by a different process identity".to_string());
429 }
430
431 self.agents.push(AgentEntry {
436 agent_id: agent_id.clone(),
437 agent_type: agent_type.to_string(),
438 role: role.map(std::string::ToString::to_string),
439 project_root: project_root.to_string(),
440 started_at: Utc::now(),
441 last_active: Utc::now(),
442 pid,
443 process_identity: Some(identity),
444 durable_identity_id: durable_identity_id.map(str::to_string),
445 status: AgentStatus::Active,
446 status_message: None,
447 transport: meta.transport,
448 role_source: meta.role_source,
449 parent: None,
450 });
451
452 self.updated_at = Utc::now();
453 crate::core::events::emit_agent_action(&agent_id, "register", None);
454 Ok(agent_id)
455 }
456
457 pub fn register_mcp_process(
459 project_root: &str,
460 durable_identity_id: Option<&str>,
461 ) -> Result<String, String> {
462 Self::register_mcp_process_as(
463 project_root,
464 PLACEHOLDER_ROLE,
465 RoleSource::Assumed,
466 durable_identity_id,
467 )
468 }
469
470 pub fn register_mcp_process_as(
475 project_root: &str,
476 role: &str,
477 role_source: RoleSource,
478 durable_identity_id: Option<&str>,
479 ) -> Result<String, String> {
480 let transport = TransportClient::current();
481 mutate_persistent(|registry| {
482 registry.cleanup_stale(presence_ttl());
483 registry.register_transport(
484 role,
485 role_source,
486 project_root,
487 durable_identity_id,
488 transport,
489 )
490 })
491 .and_then(|result| result)
492 }
493
494 pub fn record_transport_client_persistent(
498 transport: &TransportClient,
499 ) -> Result<usize, String> {
500 mutate_persistent(|registry| registry.record_transport_client(transport))
501 }
502
503 pub(crate) fn record_transport_client(&mut self, transport: &TransportClient) -> usize {
504 let mut updated = 0;
505 for agent in self.agents.iter_mut().filter(|agent| {
506 agent.transport.is_some()
507 && agent.status != AgentStatus::Finished
508 && owned_by_current_process(agent)
509 }) {
510 agent.transport = Some(transport.clone());
511 updated += 1;
512 }
513 if updated > 0 {
514 self.updated_at = Utc::now();
515 }
516 updated
517 }
518
519 pub fn transport_presence_is_live(agent_id: &str) -> bool {
523 Self::load().is_some_and(|registry| {
524 registry.agents.iter().any(|agent| {
525 agent.agent_id == agent_id
526 && agent.status != AgentStatus::Finished
527 && owned_by_current_process(agent)
528 })
529 })
530 }
531
532 pub fn any_live_mcp_transport() -> bool {
538 let Some(registry) = Self::load() else {
539 return false;
540 };
541 let identities = ProcessIdentityIndex::load();
542 registry.agents.iter().any(|agent| {
543 agent.transport.is_some()
544 && agent.status != AgentStatus::Finished
545 && process_identity_matches(agent, &identities)
546 })
547 }
548
549 pub fn heartbeat_persistent(agent_id: &str) -> Result<(), String> {
554 mutate_persistent(|registry| registry.update_transport_heartbeat(agent_id))?
555 }
556
557 pub(crate) fn update_transport_heartbeat(&mut self, agent_id: &str) -> Result<(), String> {
558 if self.revive_lease_expired(agent_id) {
559 note_renewed(agent_id);
560 }
561 self.update_heartbeat(agent_id)
562 }
563
564 fn revive_lease_expired(&mut self, agent_id: &str) -> bool {
568 let Some(agent) = self
569 .agents
570 .iter_mut()
571 .find(|agent| agent.agent_id == agent_id)
572 else {
573 return false;
574 };
575 if agent.status == AgentStatus::Finished
576 && agent.status_message.as_deref() == Some(WORKER_LEASE_EXPIRED)
577 && agent.durable_identity_id.is_none()
578 && owned_by_current_process(agent)
579 {
580 agent.status = AgentStatus::Active;
581 agent.status_message = None;
582 return true;
583 }
584 false
585 }
586
587 pub fn renew_worker(&mut self, agent_id: &str) -> Result<bool, String> {
592 let revived = self.revive_lease_expired(agent_id);
593 self.update_heartbeat(agent_id)?;
594 if revived {
595 note_renewed(agent_id);
596 request_announce();
597 }
598 Ok(revived)
599 }
600
601 pub fn renew_worker_persistent(agent_id: &str) -> Result<bool, String> {
603 mutate_persistent(|registry| registry.renew_worker(agent_id))?
604 }
605
606 pub fn finish_persistent(agent_id: &str) -> Result<(), String> {
608 mutate_persistent(|registry| {
609 registry.set_status(agent_id, AgentStatus::Finished, Some("connection closed"))
610 })?
611 }
612
613 pub fn update_heartbeat(&mut self, agent_id: &str) -> Result<(), String> {
614 let index = self
615 .agents
616 .iter()
617 .position(|agent| agent.agent_id == agent_id)
618 .ok_or_else(|| format!("agent presence '{agent_id}' was not found"))?;
619 if self.agents[index].status == AgentStatus::Finished {
620 return Err(format!(
621 "agent presence '{agent_id}' is finished; register a new process presence"
622 ));
623 }
624 self.require_active_for_presence(agent_id, "heartbeat")?;
625 let agent = &mut self.agents[index];
626 let pid = std::process::id();
627 if agent.pid != pid {
628 return Err(format!(
629 "agent presence '{agent_id}' belongs to PID {}; heartbeat came from PID {pid}",
630 agent.pid
631 ));
632 }
633 let identity = crate::ipc::process::identity(pid)
634 .ok_or_else(|| format!("cannot establish immutable process identity for PID {pid}"))?;
635 if let Some(expected) = &agent.process_identity
636 && expected != &identity
637 {
638 return Err(format!(
639 "agent presence '{agent_id}' process identity no longer matches"
640 ));
641 }
642 agent.process_identity = Some(identity);
645 agent.status = AgentStatus::Active;
646 if agent.status_message.as_deref() == Some(TRANSPORT_IDLE) {
647 agent.status_message = None;
648 }
649 agent.last_active = Utc::now();
650 Ok(())
651 }
652
653 pub fn set_status(
654 &mut self,
655 agent_id: &str,
656 status: AgentStatus,
657 message: Option<&str>,
658 ) -> Result<(), String> {
659 let index = self
660 .agents
661 .iter()
662 .position(|agent| agent.agent_id == agent_id)
663 .ok_or_else(|| format!("agent presence '{agent_id}' was not found"))?;
664 if status != AgentStatus::Finished {
667 self.require_active_for_presence(agent_id, "status transition")?;
668 }
669 let agent = &mut self.agents[index];
670 agent.status = status;
671 agent.status_message = message.map(std::string::ToString::to_string);
672 agent.last_active = Utc::now();
673 self.updated_at = Utc::now();
674 Ok(())
675 }
676 pub(crate) fn open_or_heartbeat_logical_session(
679 &mut self,
680 source: &str,
681 workspace: &str,
682 session_id: &str,
683 ) {
684 let now = Utc::now();
685 self.logical_session_telemetry_seen = true;
686 if let Some(session) = self.logical_sessions.iter_mut().find(|session| {
687 session.source == source
688 && session.workspace == workspace
689 && session.session_id == session_id
690 }) {
691 session.last_heartbeat = now;
692 } else {
693 self.logical_sessions.push(LogicalSessionPresence {
694 source: source.to_string(),
695 workspace: workspace.to_string(),
696 session_id: session_id.to_string(),
697 opened_at: now,
698 last_heartbeat: now,
699 });
700 }
701 self.updated_at = now;
702 }
703
704 pub(crate) fn close_logical_session(
705 &mut self,
706 source: &str,
707 workspace: &str,
708 session_id: &str,
709 ) -> bool {
710 self.logical_session_telemetry_seen = true;
711 let previous_len = self.logical_sessions.len();
712 self.logical_sessions.retain(|session| {
713 session.source != source
714 || session.workspace != workspace
715 || session.session_id != session_id
716 });
717 let removed = self.logical_sessions.len() != previous_len;
718 self.updated_at = Utc::now();
719 removed
720 }
721
722 pub fn cleanup_stale_logical_sessions(&mut self, max_age_seconds: u64) {
723 let seconds = i64::try_from(max_age_seconds).unwrap_or(i64::MAX);
724 let cutoff = Utc::now() - chrono::Duration::seconds(seconds);
725 self.logical_sessions
726 .retain(|session| session.last_heartbeat >= cutoff);
727 self.updated_at = Utc::now();
728 }
729
730 pub fn record_logical_session_presence(
731 event: &str,
732 source: &str,
733 workspace: &str,
734 session_id: &str,
735 ) -> Result<(), String> {
736 let valid_field = |value: &str, max_bytes: usize| {
737 !value.is_empty() && value.len() <= max_bytes && !value.chars().any(char::is_control)
738 };
739 if !valid_field(source, LOGICAL_SESSION_SOURCE_MAX_BYTES)
740 || !valid_field(workspace, LOGICAL_SESSION_WORKSPACE_MAX_BYTES)
741 || !valid_field(session_id, LOGICAL_SESSION_ID_MAX_BYTES)
742 {
743 return Err(
744 "presence fields are empty, too long, or contain control characters".to_string(),
745 );
746 }
747 if !matches!(event, "open" | "heartbeat" | "close") {
748 return Err("event must be open, heartbeat, or close".to_string());
749 }
750
751 let ttl = crate::core::config::Config::load()
752 .agents
753 .logical_session_ttl_seconds;
754 mutate_persistent(|registry| {
755 registry.cleanup_stale_logical_sessions(ttl);
756 match event {
757 "open" | "heartbeat" => {
758 registry.open_or_heartbeat_logical_session(source, workspace, session_id);
759 }
760 "close" => {
761 registry.close_logical_session(source, workspace, session_id);
762 }
763 _ => unreachable!("event validated above"),
764 }
765 })
766 }
767
768 pub fn require_active_for_presence(
772 &self,
773 agent_id: &str,
774 operation: &str,
775 ) -> Result<(), String> {
776 let Some(agent) = self.agents.iter().find(|agent| agent.agent_id == agent_id) else {
777 return Ok(());
778 };
779 let Some(durable_id) = agent.durable_identity_id.as_deref() else {
780 return Ok(());
781 };
782 if agent.status == AgentStatus::Finished {
783 return Err(format!(
784 "agent presence '{agent_id}' is finished; cannot {operation}"
785 ));
786 }
787 crate::core::agent_registry::require_active(durable_id).map_err(|error| {
788 format!("agent presence '{agent_id}' is not authorized for {operation}: {error}")
789 })
790 }
791
792 pub fn list_active(&self, project_root: Option<&str>) -> Vec<&AgentEntry> {
793 let compatibility_identities = ProcessIdentityIndex::load();
794 self.agents
795 .iter()
796 .filter(|a| {
797 if let Some(root) = project_root {
798 crate::core::pathutil::same_project(&a.project_root, root)
799 && a.status != AgentStatus::Finished
800 && process_identity_matches(a, &compatibility_identities)
801 && self
802 .require_active_for_presence(&a.agent_id, "listing")
803 .is_ok()
804 } else {
805 a.status != AgentStatus::Finished
806 && process_identity_matches(a, &compatibility_identities)
807 && self
808 .require_active_for_presence(&a.agent_id, "listing")
809 .is_ok()
810 }
811 })
812 .collect()
813 }
814
815 pub fn list_all(&self) -> &[AgentEntry] {
816 &self.agents
817 }
818
819 pub(crate) fn post_message(
820 &mut self,
821 from_agent: &str,
822 to_agent: Option<&str>,
823 category: &str,
824 message: &str,
825 ) -> Result<String, String> {
826 self.post_message_full(
827 from_agent,
828 to_agent,
829 category,
830 message,
831 PrivacyLevel::default(),
832 MessagePriority::default(),
833 None,
834 )
835 }
836
837 pub(crate) fn post_message_full(
838 &mut self,
839 from_agent: &str,
840 to_agent: Option<&str>,
841 category: &str,
842 message: &str,
843 privacy: PrivacyLevel,
844 priority: MessagePriority,
845 ttl_hours: Option<u64>,
846 ) -> Result<String, String> {
847 self.post_message_scoped(
848 None, from_agent, to_agent, category, message, privacy, priority, ttl_hours,
849 )
850 }
851
852 #[allow(clippy::too_many_arguments)]
853 pub fn post_message_scoped(
854 &mut self,
855 project_root: Option<&str>,
856 from_agent: &str,
857 to_agent: Option<&str>,
858 category: &str,
859 message: &str,
860 privacy: PrivacyLevel,
861 priority: MessagePriority,
862 ttl_hours: Option<u64>,
863 ) -> Result<String, String> {
864 self.post_message_keyed(
865 project_root,
866 from_agent,
867 to_agent,
868 category,
869 message,
870 privacy,
871 priority,
872 ttl_hours,
873 None,
874 )
875 }
876
877 #[allow(clippy::too_many_arguments)]
884 pub fn post_message_keyed(
885 &mut self,
886 project_root: Option<&str>,
887 from_agent: &str,
888 to_agent: Option<&str>,
889 category: &str,
890 message: &str,
891 privacy: PrivacyLevel,
892 priority: MessagePriority,
893 ttl_hours: Option<u64>,
894 request_id: Option<&str>,
895 ) -> Result<String, String> {
896 self.require_active_for_presence(from_agent, "posting a message")?;
897 if let Some(target) = to_agent {
898 self.require_active_for_presence(target, "receiving a message")?;
899 }
900 let request = match request_id {
901 Some(key) => {
902 super::check_request_id(key)?;
903 let digest = super::request_digest(
904 "leanctx/agent-post/v1",
905 &serde_json::json!([
906 project_root,
907 to_agent,
908 category,
909 message,
910 privacy,
911 priority,
912 ttl_hours
913 ]),
914 );
915 let now = Utc::now();
916 if let Some(entry) = self.scratchpad.iter().find(|entry| {
917 entry.from_agent == from_agent
918 && entry.project_root.as_deref() == project_root
919 && entry.expires_at.is_none_or(|expires| expires > now)
920 && entry
921 .metadata
922 .get(REQUEST_ID_METADATA_KEY)
923 .map(String::as_str)
924 == Some(key)
925 }) {
926 return if entry.metadata.get(REQUEST_DIGEST_METADATA_KEY) == Some(&digest) {
927 Ok(entry.id.clone())
928 } else {
929 Err(super::request_id_conflict())
930 };
931 }
932 Some((key, digest))
933 }
934 None => None,
935 };
936 let (message, mut metadata) =
940 ScratchpadEntry::admit_message(from_agent, project_root, message)?;
941 if let Some((key, digest)) = request {
942 metadata.insert(REQUEST_ID_METADATA_KEY.to_owned(), key.to_owned());
943 metadata.insert(REQUEST_DIGEST_METADATA_KEY.to_owned(), digest);
944 }
945 let id = generate_short_id();
946 let default_ttl_hours = crate::core::config::Config::load()
947 .agents
948 .scratchpad_default_ttl_hours;
949 let expires_at = Some(match ttl_hours {
950 Some(hours) => Utc::now() + chrono::Duration::hours(hours as i64),
951 None => Utc::now() + chrono::Duration::hours(default_ttl_hours as i64),
952 });
953 self.scratchpad.push(ScratchpadEntry {
954 id: id.clone(),
955 from_agent: from_agent.to_string(),
956 to_agent: to_agent.map(std::string::ToString::to_string),
957 task_id: None,
958 category: category.to_string(),
959 priority,
960 privacy,
961 message,
962 metadata,
963 project_root: project_root.map(std::string::ToString::to_string),
964 timestamp: Utc::now(),
965 read_by: vec![from_agent.to_string()],
966 expires_at,
967 });
968
969 let max_scratchpad_entries = max_scratchpad();
970 if self.scratchpad.len() > max_scratchpad_entries {
971 self.scratchpad
972 .drain(0..self.scratchpad.len() - max_scratchpad_entries);
973 }
974
975 self.updated_at = Utc::now();
976 Ok(id)
977 }
978
979 #[cfg(test)]
980 pub(crate) fn read_unread(&mut self, agent_id: &str) -> Result<Vec<&ScratchpadEntry>, String> {
981 self.read_unread_for_project(agent_id, None)
982 }
983
984 #[cfg(test)]
985 pub(crate) fn read_unread_scoped(
986 &mut self,
987 agent_id: &str,
988 project_root: &str,
989 ) -> Result<Vec<&ScratchpadEntry>, String> {
990 self.read_unread_for_project(agent_id, Some(project_root))
991 }
992
993 pub fn read_unread_page(
999 &mut self,
1000 agent_id: &str,
1001 project_root: &str,
1002 budget: usize,
1003 render: impl Fn(&ScratchpadEntry) -> String,
1004 ) -> Result<(Vec<String>, usize), String> {
1005 self.require_active_for_presence(agent_id, "reading messages")?;
1006 let now = Utc::now();
1007 let floor = self.broadcast_floor(agent_id);
1008 let unread: Vec<usize> = self
1009 .scratchpad
1010 .iter()
1011 .enumerate()
1012 .filter(|(_, e)| unread_for(e, agent_id, Some(project_root), now, floor))
1013 .map(|(i, _)| i)
1014 .collect();
1015 let (mut shown, mut used) = (Vec::new(), 0_usize);
1016 for &index in &unread {
1017 let line = render(&self.scratchpad[index]);
1018 if !shown.is_empty() && used + line.len() > budget {
1019 break;
1020 }
1021 used += line.len();
1022 shown.push(line);
1023 self.scratchpad[index].read_by.push(agent_id.to_string());
1024 }
1025 if !shown.is_empty() {
1026 self.updated_at = now;
1027 }
1028 let left = unread.len() - shown.len();
1029 Ok((shown, left))
1030 }
1031
1032 pub(crate) fn has_unread_scoped(&self, agent_id: &str, project_root: &str) -> bool {
1035 let now = Utc::now();
1036 let floor = self.broadcast_floor(agent_id);
1037 self.scratchpad
1038 .iter()
1039 .any(|e| unread_for(e, agent_id, Some(project_root), now, floor))
1040 }
1041
1042 pub fn wait_for_unread(
1047 agent_id: &str,
1048 project_root: &str,
1049 timeout: std::time::Duration,
1050 mut on_poll: impl FnMut(),
1051 ) -> bool {
1052 let path = agents_dir().ok().map(|dir| dir.join("registry.json"));
1053 let modified = || {
1054 path.as_ref()
1055 .and_then(|p| std::fs::metadata(p).ok())
1056 .and_then(|m| m.modified().ok())
1057 };
1058 let deadline = std::time::Instant::now() + timeout;
1059 let mut last_poll = std::time::Instant::now();
1060 let mut seen = None;
1061 loop {
1062 let now_modified = modified();
1063 if seen.is_none() || now_modified != seen.flatten() {
1064 seen = Some(now_modified);
1065 if Self::load().is_some_and(|r| r.has_unread_scoped(agent_id, project_root)) {
1066 return true;
1067 }
1068 let racy = now_modified.is_some_and(|mtime| {
1072 !std::time::SystemTime::now()
1073 .duration_since(mtime)
1074 .is_ok_and(|age| age >= WAIT_RACY_MTIME)
1075 });
1076 if racy {
1077 seen = None;
1078 }
1079 }
1080 let now = std::time::Instant::now();
1081 if now >= deadline {
1082 return false;
1083 }
1084 if now.duration_since(last_poll) >= WAIT_KEEPALIVE {
1085 on_poll();
1086 last_poll = now;
1087 seen = None;
1089 }
1090 std::thread::sleep(WAIT_POLL.min(deadline - now));
1091 }
1092 }
1093
1094 #[cfg(test)]
1095 fn read_unread_for_project(
1096 &mut self,
1097 agent_id: &str,
1098 project_root: Option<&str>,
1099 ) -> Result<Vec<&ScratchpadEntry>, String> {
1100 self.require_active_for_presence(agent_id, "reading messages")?;
1101 let now = Utc::now();
1102 let floor = self.broadcast_floor(agent_id);
1103 let unread_indices: Vec<usize> = self
1104 .scratchpad
1105 .iter()
1106 .enumerate()
1107 .filter(|(_, e)| unread_for(e, agent_id, project_root, now, floor))
1108 .map(|(i, _)| i)
1109 .collect();
1110
1111 for i in &unread_indices {
1112 self.scratchpad[*i].read_by.push(agent_id.to_string());
1113 }
1114
1115 self.updated_at = Utc::now();
1116
1117 Ok(unread_indices
1118 .into_iter()
1119 .map(|index| &self.scratchpad[index])
1120 .collect())
1121 }
1122
1123 pub fn cleanup_stale(&mut self, max_age_hours: u64) {
1124 let now = Utc::now();
1125 let cutoff = now - chrono::Duration::hours(max_age_hours as i64);
1126 let worker_cutoff = now - chrono::Duration::seconds(active_worker_lease_seconds());
1127 let mut compatibility_identities = ProcessIdentityIndex::load();
1128 let mut identities_changed = false;
1129 let mut newly_certified = Vec::new();
1130
1131 for agent in &mut self.agents {
1132 if agent.status == AgentStatus::Finished {
1133 if is_recoverable_legacy_finished(agent) {
1134 if let Some(identity) = safe_legacy_identity(agent) {
1135 identities_changed |=
1136 compatibility_identities.insert(&agent.agent_id, &identity);
1137 newly_certified.push(agent.agent_id.clone());
1138 agent.status = AgentStatus::Active;
1139 agent.status_message =
1140 Some("legacy process identity recovered".to_string());
1141 }
1142 }
1143 continue;
1144 }
1145 if let Some(identity) = &agent.process_identity {
1146 identities_changed |= compatibility_identities.insert(&agent.agent_id, identity);
1147 }
1148 if process_identity_matches(agent, &compatibility_identities) {
1149 if agent.status == AgentStatus::Active && agent.last_active < worker_cutoff {
1158 if agent.transport.is_some() {
1159 agent.status = AgentStatus::Idle;
1160 agent.status_message = Some(TRANSPORT_IDLE.to_string());
1161 } else {
1162 agent.status = AgentStatus::Finished;
1163 agent.status_message = Some(WORKER_LEASE_EXPIRED.to_string());
1164 }
1165 }
1166 continue;
1167 }
1168 if let Some(identity) = safe_legacy_identity(agent) {
1169 identities_changed |= compatibility_identities.insert(&agent.agent_id, &identity);
1170 newly_certified.push(agent.agent_id.clone());
1171 continue;
1172 }
1173 {
1174 agent.status = AgentStatus::Finished;
1175 agent.status_message = Some("process identity no longer matches".to_string());
1176 }
1177 }
1178
1179 let mut finished_by_recency: Vec<_> = self
1183 .agents
1184 .iter()
1185 .filter(|agent| agent.status == AgentStatus::Finished && agent.last_active >= cutoff)
1186 .collect();
1187 finished_by_recency.sort_unstable_by(|left, right| {
1188 right
1189 .last_active
1190 .cmp(&left.last_active)
1191 .then_with(|| left.agent_id.cmp(&right.agent_id))
1192 });
1193 let retained_finished_ids: HashSet<String> = finished_by_recency
1194 .into_iter()
1195 .take(MAX_RETAINED_FINISHED_AGENTS)
1196 .map(|agent| agent.agent_id.clone())
1197 .collect();
1198
1199 self.agents.retain(|a| {
1202 let retire = a.status == AgentStatus::Finished
1203 && (a.last_active < cutoff || !retained_finished_ids.contains(&a.agent_id));
1204 if retire {
1205 crate::core::agent_budget::remove(&a.agent_id);
1206 }
1207 !retire
1208 });
1209 identities_changed |= compatibility_identities.retain_agents(self);
1210 if identities_changed && compatibility_identities.save().is_err() {
1211 for agent in &mut self.agents {
1212 if newly_certified.contains(&agent.agent_id) {
1213 agent.status = AgentStatus::Finished;
1214 agent.status_message =
1215 Some("legacy process identity could not be persisted".to_string());
1216 }
1217 }
1218 }
1219
1220 let now = Utc::now();
1222 self.scratchpad
1223 .retain(|entry| entry.expires_at.is_none_or(|exp| exp > now));
1224
1225 self.updated_at = Utc::now();
1226 }
1227
1228 #[cfg(any(test, feature = "test-support"))]
1229 pub fn save(&self) -> Result<(), String> {
1230 let dir = agents_dir()?;
1231 create_agents_dir(&dir)?;
1232
1233 let lock_path = dir.join("registry.lock");
1234 let _lock = FileLock::acquire(&lock_path)?;
1235
1236 self.save_locked(&dir)
1237 }
1238
1239 fn save_locked(&self, dir: &std::path::Path) -> Result<(), String> {
1240 let path = dir.join("registry.json");
1241 save_registry_file(&path, self)
1242 }
1243
1244 pub fn load() -> Option<Self> {
1245 let dir = agents_dir().ok()?;
1246 let path = dir.join("registry.json");
1247 load_registry_file(&path).ok().flatten()
1248 }
1249
1250 pub fn load_or_create() -> Self {
1251 Self::load().unwrap_or_default()
1252 }
1253
1254 pub fn mutate_locked<T>(f: impl FnOnce(&mut Self) -> T) -> Result<(Self, T), String> {
1264 let dir = agents_dir()?;
1265 create_agents_dir(&dir)?;
1266
1267 let lock_path = dir.join("registry.lock");
1268 let _lock = FileLock::acquire(&lock_path)?;
1269
1270 let path = dir.join("registry.json");
1271 let mut registry = load_registry_file(&path)?.unwrap_or_default();
1272 let out = f(&mut registry);
1273 registry.save_locked(&dir)?;
1274 Ok((registry, out))
1275 }
1276}
1277
1278impl Default for AgentRegistry {
1279 fn default() -> Self {
1280 Self::new()
1281 }
1282}
1283
1284static ANNOUNCE: std::sync::OnceLock<fn()> = std::sync::OnceLock::new();
1288
1289pub fn register_announce(request: fn()) {
1291 let _ = ANNOUNCE.set(request);
1292}
1293
1294fn request_announce() {
1295 if let Some(request) = ANNOUNCE.get() {
1296 request();
1297 }
1298}
1299
1300#[cfg(test)]
1301#[path = "registry_tests.rs"]
1302mod tests;
1303
1304#[cfg(test)]
1305#[path = "registry_logical_session_tests.rs"]
1306mod logical_session_tests;
1307#[cfg(test)]
1309#[path = "registry_presence_tests.rs"]
1310mod presence_tests;