Skip to main content

lean_ctx/core/agents/
registry.rs

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
17/// Role an MCP transport holds until `initialize` resolves the session role.
18const PLACEHOLDER_ROLE: &str = "context-engine";
19const WORKER_LEASE_EXPIRED: &str = "worker lease expired; register again before continuing";
20/// A live MCP connection without a recent tool call is idle, not gone: the
21/// worker lease governs collaboration slots, never transport presence.
22const TRANSPORT_IDLE: &str = "connected; no tool call within the worker lease";
23
24/// Registration metadata beyond the legacy `register` arguments.
25#[derive(Default)]
26struct RegistrationMeta {
27    role_source: Option<RoleSource>,
28    transport: Option<TransportClient>,
29}
30
31/// Explicit callers (`ctx_agent register`) declare their role; only the
32/// transport placeholder is assumed.
33fn 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
41/// Agents of this process whose expired registration a heartbeat renewed,
42/// not yet reported by `ctx_agent read`/`wait`.
43static 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
57/// Whether `agent_id`'s registration had expired and was renewed since the
58/// last call (each renewal is reported once).
59pub fn take_renewed(agent_id: &str) -> bool {
60    RENEWED
61        .lock()
62        .is_ok_and(|mut renewed| renewed.remove(agent_id))
63}
64
65/// How often `wait` looks at the registry file's modification time.
66const WAIT_POLL: std::time::Duration = std::time::Duration::from_millis(500);
67/// How often `wait` keeps the waiting agent's lease alive.
68const WAIT_KEEPALIVE: std::time::Duration = std::time::Duration::from_secs(30);
69/// A registry whose modification time is this recent may be replaced again
70/// within the same timestamp tick (coarse kernel clocks, 1–2 s on HFS+/FAT),
71/// so an unchanged mtime does not yet prove an unchanged file.
72const WAIT_RACY_MTIME: std::time::Duration = std::time::Duration::from_secs(3);
73
74/// How far before its registration an agent still receives broadcasts: the
75/// seconds a reconnect leaves between the old session's last read and the new
76/// one, with room to spare.
77const BROADCAST_GRACE: chrono::Duration = chrono::Duration::minutes(10);
78
79/// A message `agent_id` has not read: addressed to it, or a broadcast posted
80/// after `floor` (the agent's registration less [`BROADCAST_GRACE`]), not its
81/// own, in `project_root` (any when `None`), not expired. Without the floor a
82/// reconnected session (a new id) got hours of old broadcasts as unread
83/// (25.10.: 123 after the MCP reconnect).
84fn 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
103/// True when `agent` is owned by this exact process (PID and start marker).
104fn 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;
115/// Active collaboration slots are leases, not process guesses: MCP workers can
116/// share the daemon PID, so process identity alone cannot distinguish sessions.
117/// Workers renew via `read` or `status=active`; expiry releases abandoned slots.
118const 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    /// Registers (or refreshes) `role` as its own presence next to
235    /// `parent_id`, which this process holds: a subagent shares its parent's
236    /// MCP connection, and under the parent's id its reads consumed the
237    /// messages directed to the parent (WEB-2 read the lead's, 25.10.).
238    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    /// The oldest broadcast `agent_id` still receives: its registration less
287    /// [`BROADCAST_GRACE`]; `None` (all) for an id the registry doesn't know.
288    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    /// Whether `agent_id` is a live presence this very process registered
296    /// (its own or a child): only those may be named as `agent_id` on a call.
297    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    /// The declared role of `agent_id`, if it has one.
310    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    /// Registers (or refreshes) the presence of this process's own MCP
318    /// connection, attributed to its host and with the role's provenance.
319    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            // A transport refresh keeps the type an agent declared through
385            // `ctx_agent register` on the same process.
386            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            // #1766: the presence retry registers as the `context-engine`
391            // placeholder. It never replaces a role the process already holds
392            // (a `reviewer` silently became a context engine); an explicit role
393            // still replaces the placeholder — the `initialize` upgrade. An
394            // assumed role (heuristic/default) never replaces a declared one.
395            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            // Never downgrade a host the handshake identified to `unknown`.
407            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        // A legacy record or a PID-reused record can share this numeric PID.
419        // Retire it before admitting the new owner; a bare PID is never enough
420        // to keep a presence alive.
421        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        // Registration is identity/presence bookkeeping, not resource admission.
432        // Any number of agents may join the bus; scarce work (builds, tests and
433        // process resources) is bounded where it is actually consumed.
434
435        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    /// Atomically registers this MCP process in the shared on-disk registry.
458    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    /// Registers this MCP process with the role the session resolved at
471    /// `initialize` (#1766), so the presence retry does not fall back to the
472    /// `context-engine` placeholder. The host comes from this process's
473    /// recorded MCP handshake.
474    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    /// Attributes this process's live transport presences to the host from
495    /// its MCP handshake. Covers sessions whose `initialize` registers no
496    /// project-scoped presence (no resolvable root).
497    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    /// Whether `agent_id` is still a listed presence owned by this process.
520    /// Read-only: used by the MCP server to notice a presence that another
521    /// process retired (for example an older binary's lease expiry) or pruned.
522    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    /// Whether any LeanCTX MCP server runs on this machine: a transport
533    /// presence that is not finished and whose recorded process is still the
534    /// one that registered it. Every MCP connection registers one, the stdio
535    /// server a host starts as well as the daemon's sessions. Read-only; the
536    /// deny hook uses it to tell "no daemon" from "no MCP server at all".
537    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    /// Atomically refreshes a registered MCP process heartbeat. The caller is
550    /// the transport itself, so a presence that only lost its worker lease is
551    /// revived rather than rejected (an older binary in another process still
552    /// finishes quiet transports).
553    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    /// Revive `agent_id` when only its worker lease ran out: an unbound
565    /// presence of this very process that `cleanup_stale` finished for
566    /// being quiet. `true` when it was revived.
567    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    /// The heartbeat of an agent this process registered (`ctx_agent
588    /// register`), sent on every tool call of its session: renews its worker
589    /// lease, and revives it when the lease had already run out. `Ok(true)`
590    /// when it was revived; [`take_renewed`] then says so once.
591    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    /// [`Self::renew_worker`] under the registry lock.
602    pub fn renew_worker_persistent(agent_id: &str) -> Result<bool, String> {
603        mutate_persistent(|registry| registry.renew_worker(agent_id))?
604    }
605
606    /// Atomically marks a registered MCP process as finished.
607    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        // Upgrade legacy records only from their owning process. This avoids a
643        // migration gap while still rejecting a PID reused by unrelated work.
644        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        // Finished is the safe cleanup path: reapers and shutdown must be
665        // able to release a presence after its durable identity is revoked.
666        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    /// Records explicit logical-session presence supplied by an owning editor
677    /// integration. Tool activity is deliberately never treated as a session.
678    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    /// Gate operations for an explicitly bound presence. Legacy unbound
769    /// entries retain their prior behavior until a migration enables a
770    /// mandatory-binding mode.
771    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    /// [`Self::post_message_scoped`] with an optional idempotency key
878    /// (AP07-G9). A key is scoped to the sender and project: a retry with the
879    /// same key and the same request returns the id of the message it first
880    /// posted and writes nothing; the same key with another request is
881    /// refused. A key lives as long as its message — the scratchpad's entry
882    /// cap and TTL bound both — and an expired message frees its key.
883    #[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        // Admission precedes persistence: only admitted text is stored, with
937        // the binding a later read re-authorizes. A withheld message is refused
938        // with a typed, content-free reason and nothing is written.
939        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    /// The unread messages of `agent_id` in `project_root`, oldest first, as
994    /// `render` shows them, up to `budget` bytes (at least one); only those
995    /// are marked read. Returns them and how many stay unread for the next
996    /// read: a host that cuts a long answer used to lose the rest, already
997    /// marked read (WEB saw 14 of 55, 25.10.).
998    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    /// Whether `agent_id` has a message waiting in `project_root` (what
1033    /// [`Self::read_unread_scoped`] would return), without marking it read.
1034    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    /// Block until `agent_id` has a message waiting in `project_root`
1043    /// (`true`) or `timeout` passed (`false`). Looks at the registry only when
1044    /// its file changed (polled every `WAIT_POLL`); `on_poll` runs about every
1045    /// 30 s meanwhile (the caller keeps the agent's lease alive there).
1046    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                // A post replacing the file in the same timestamp tick as the
1069                // version just read leaves the mtime unchanged; without this
1070                // the wait slept through it until the next keepalive (30 s).
1071                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                // Look again even without a visible change (coarse mtimes).
1088                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                // A live MCP process is not proof that its worker is still
1150                // participating.  The worker lease is renewed by `read` or
1151                // `status=active`; expire quiet Active presences even when the
1152                // hosting daemon itself remains alive.  This prevents stale
1153                // sessions from occupying capacity or appearing as peers.
1154                // The MCP server's own transport presence is the exception:
1155                // its process is the connection, so it idles instead of
1156                // vanishing while the editor keeps it open.
1157                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        // Presence is operational state, not an audit log. Keep the newest
1180        // completed sessions for diagnostics but bound their in-memory/on-disk
1181        // history so a burst of short-lived agents cannot slow later checks.
1182        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        // Drop each retired agent's budget entry too — a finished/dead agent can't read
1200        // again, so removing its budget loses no live enforcement and bounds BUDGETS.
1201        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        // Remove expired scratchpad entries.
1221        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    /// Atomically load, mutate, and persist the registry under a single file
1255    /// lock. `load_or_create()` + mutate + `save()` is a read-modify-write
1256    /// race: `save()` only locks the final write, so two concurrent callers
1257    /// (two MCP sessions registering, or the dashboard's own poll-triggered
1258    /// `cleanup_stale` + save) can each load a stale snapshot and the last
1259    /// writer silently drops the other's changes — e.g. a second session's
1260    /// registration vanishing from the dashboard. Holding the lock across
1261    /// the re-read closes that window: the read inside always sees the
1262    /// latest on-disk state.
1263    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
1284/// "Announce this machine's agents now", from the Hive connector when it is
1285/// linked in (`core::hive::register_hooks`): a revived worker is on the bus
1286/// again before its next claim. Bookkeeping only; without it nothing waits.
1287static ANNOUNCE: std::sync::OnceLock<fn()> = std::sync::OnceLock::new();
1288
1289/// Register the announce request; the first registration wins.
1290pub 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// SPDX-License-Identifier: Apache-2.0
1308#[cfg(test)]
1309#[path = "registry_presence_tests.rs"]
1310mod presence_tests;