Skip to main content

objects/store/
actor_presence.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Durable actor presence and work-context records.
3
4use std::{
5    collections::{HashMap, HashSet},
6    path::{Path, PathBuf},
7};
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12use crate::{
13    fs_atomic::write_file_atomic,
14    lock::RepoLock,
15    store::{HeddleError, Result},
16};
17
18const STALE_AGENT_TTL_DAYS: i64 = 7;
19
20/// A record of one `heddle context get` call made during an agent session.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ContextQueryEntry {
23    /// The file path that was queried.
24    pub path: String,
25    /// The scope filter used, if any (e.g. `symbol:parse_manifest`).
26    pub scope: Option<String>,
27    /// When the query was made.
28    pub queried_at: DateTime<Utc>,
29}
30
31#[derive(Debug, Clone, Default, Serialize, Deserialize)]
32pub struct AgentUsageSummary {
33    #[serde(default)]
34    pub input_tokens: Option<u64>,
35    #[serde(default)]
36    pub output_tokens: Option<u64>,
37    #[serde(default)]
38    pub reasoning_tokens: Option<u64>,
39    #[serde(default)]
40    pub tool_calls: Option<u32>,
41    #[serde(default)]
42    pub cost_micros_usd: Option<u64>,
43}
44
45/// Status of an agent session.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum ActorPresenceStatus {
49    /// Agent is actively working.
50    Active,
51    /// Agent work was abandoned or interrupted.
52    Abandoned,
53    /// Agent has finished work (snapshot taken) but not yet merged.
54    Complete,
55    /// Agent's thread has been merged into the base thread.
56    Merged,
57}
58
59impl std::fmt::Display for ActorPresenceStatus {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        match self {
62            ActorPresenceStatus::Active => write!(f, "active"),
63            ActorPresenceStatus::Abandoned => write!(f, "abandoned"),
64            ActorPresenceStatus::Complete => write!(f, "complete"),
65            ActorPresenceStatus::Merged => write!(f, "merged"),
66        }
67    }
68}
69
70/// A registry entry describing one active (or recently finished) agent session.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct ActorPresence {
73    /// Unique session identifier (e.g. `agent-xxxxxxxxxxxx`).
74    pub session_id: String,
75    /// Stable harness-side instance identifier used to reconnect the same
76    /// local client process to its registry entry across bridge restarts.
77    #[serde(default)]
78    pub client_instance_id: Option<String>,
79    /// Harness-native actor identity such as `codex:thread:thr_123`.
80    #[serde(default)]
81    pub native_actor_key: Option<String>,
82    /// Harness-native parent actor identity for child/subagent sessions.
83    #[serde(default)]
84    pub native_parent_actor_key: Option<String>,
85    /// Harness-native reconnect key such as a transcript path or client name.
86    #[serde(default)]
87    pub native_instance_key: Option<String>,
88    /// Heddle session identifier when this registry entry is attached to a
89    /// first-class Heddle multi-segment session.
90    #[serde(default)]
91    pub heddle_session_id: Option<String>,
92    /// Thread identifier when the session is attached to a Heddle thread record.
93    #[serde(default)]
94    pub thread_id: Option<String>,
95    /// The Heddle thread the agent writes to.
96    pub thread: String,
97    /// Full state id the session was anchored to.
98    #[serde(default)]
99    pub anchor_state: Option<String>,
100    /// Root tree id the session was anchored to.
101    #[serde(default)]
102    pub anchor_root: Option<String>,
103    /// Absolute path to the agent's checkout directory, if filesystem-based.
104    #[serde(default)]
105    pub path: Option<PathBuf>,
106    /// Short display form of the base state the agent started from.
107    pub base_state: String,
108    /// When the agent session was created.
109    pub started_at: DateTime<Utc>,
110    /// AI provider (e.g. `anthropic`).
111    pub provider: Option<String>,
112    /// AI model (e.g. `claude-sonnet-4-6`).
113    pub model: Option<String>,
114    /// Harness or client name (e.g. `claude-code`, `codex`).
115    #[serde(default)]
116    pub harness: Option<String>,
117    /// Harness-specific reasoning/thinking level when available.
118    #[serde(default)]
119    pub thinking_level: Option<String>,
120    /// Aggregated usage counters captured for the active session.
121    #[serde(default)]
122    pub usage_summary: AgentUsageSummary,
123    /// Most recent progress heartbeat timestamp.
124    #[serde(default)]
125    pub last_progress_at: Option<DateTime<Utc>>,
126    /// Summary flush state for the local session reporter.
127    #[serde(default)]
128    pub report_flush_state: Option<String>,
129    /// Most recent explanation of why Heddle attached this actor to its current
130    /// thread/session context.
131    #[serde(default)]
132    pub attach_reason: Option<String>,
133    /// Local agent task assignment id this session is executing, if any.
134    #[serde(default)]
135    pub task_assignment_id: Option<String>,
136    /// Ordered explanation of attach rules Heddle evaluated.
137    #[serde(default)]
138    pub attach_precedence: Vec<String>,
139    /// The attach rule that won for this actor.
140    #[serde(default)]
141    pub winning_attach_rule: Option<String>,
142    /// Where Heddle learned the harness identity from.
143    #[serde(default)]
144    pub probe_source: Option<String>,
145    /// How confident Heddle was in the probe result.
146    #[serde(default)]
147    pub probe_confidence: Option<f32>,
148    /// Current status.
149    pub status: ActorPresenceStatus,
150    /// When the agent was marked complete or merged.
151    #[serde(default)]
152    pub completed_at: Option<DateTime<Utc>>,
153    /// Log of `heddle context get` calls made during this session.
154    /// Appended by the CLI each time an agent queries context from its worktree.
155    #[serde(default)]
156    pub context_queries: Vec<ContextQueryEntry>,
157}
158
159/// One hop in an actor ancestry chain, ordered root to leaf.
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
161pub struct ActorChainNode {
162    pub session_id: String,
163    #[serde(default)]
164    pub native_actor_key: Option<String>,
165    #[serde(default)]
166    pub native_parent_actor_key: Option<String>,
167    pub thread: String,
168    pub status: ActorPresenceStatus,
169    #[serde(default)]
170    pub provider: Option<String>,
171    #[serde(default)]
172    pub model: Option<String>,
173    #[serde(default)]
174    pub harness: Option<String>,
175}
176
177impl From<&ActorPresence> for ActorChainNode {
178    fn from(entry: &ActorPresence) -> Self {
179        Self {
180            session_id: entry.session_id.clone(),
181            native_actor_key: entry.native_actor_key.clone(),
182            native_parent_actor_key: entry.native_parent_actor_key.clone(),
183            thread: entry.thread.clone(),
184            status: entry.status.clone(),
185            provider: entry.provider.clone(),
186            model: entry.model.clone(),
187            harness: entry.harness.clone(),
188        }
189    }
190}
191
192/// Manages actor presence stored in `.heddle/actor-presence/`.
193pub struct ActorPresenceStore {
194    presence_dir: PathBuf,
195}
196
197impl ActorPresenceStore {
198    /// Create a store backed by `<heddle_dir>/actor-presence/`.
199    pub fn new(heddle_dir: &Path) -> Self {
200        Self {
201            presence_dir: heddle_dir.join("actor-presence"),
202        }
203    }
204
205    fn entry_path(&self, session_id: &str) -> Result<PathBuf> {
206        // Only allow characters produced by generate_actor_session_id: lowercase
207        // alphanumeric and hyphens.  This makes path traversal structurally
208        // impossible: none of [a-z0-9-] can form ".." or "/".
209        if session_id.is_empty()
210            || !session_id
211                .bytes()
212                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
213        {
214            return Err(HeddleError::Config(format!(
215                "invalid session ID '{}': only lowercase alphanumeric and hyphens allowed",
216                session_id
217            )));
218        }
219        Ok(self.presence_dir.join(format!("{}.toml", session_id)))
220    }
221
222    fn lock_path(&self) -> PathBuf {
223        self.presence_dir.join(".lock")
224    }
225
226    fn write_lock(&self) -> Result<crate::lock::WriteLockGuard> {
227        RepoLock::at(self.lock_path()).write().map_err(|err| {
228            HeddleError::Config(format!("failed to acquire agent registry lock: {err}"))
229        })
230    }
231
232    fn write_entry_file(&self, entry: &ActorPresence) -> Result<()> {
233        crate::fs_atomic::create_dir_all_durable(&self.presence_dir)?;
234        let path = self.entry_path(&entry.session_id)?;
235        let content =
236            toml::to_string_pretty(entry).map_err(|e| HeddleError::Config(e.to_string()))?;
237        Ok(write_file_atomic(&path, content.as_bytes())?)
238    }
239
240    fn load_entry_from_path(&self, path: &Path) -> Result<Option<ActorPresence>> {
241        if !path.exists() {
242            return Ok(None);
243        }
244
245        let content = std::fs::read_to_string(path)?;
246        let entry = toml::from_str(&content).map_err(|e| HeddleError::Config(e.to_string()))?;
247        Ok(Some(entry))
248    }
249
250    fn is_stale_terminal_entry(&self, entry: &ActorPresence) -> bool {
251        if matches!(entry.status, ActorPresenceStatus::Active) {
252            return false;
253        }
254
255        let terminal_at = entry.completed_at.unwrap_or(entry.started_at);
256        terminal_at <= Utc::now() - chrono::Duration::days(STALE_AGENT_TTL_DAYS)
257    }
258
259    fn prune_stale_entry_path(&self, path: &Path) -> Result<()> {
260        if path.exists() {
261            std::fs::remove_file(path)?;
262        }
263        Ok(())
264    }
265
266    pub fn current_entries(&self) -> Result<Vec<ActorPresence>> {
267        self.list()
268    }
269
270    pub fn active_entries(&self) -> Result<Vec<ActorPresence>> {
271        Ok(self
272            .current_entries()?
273            .into_iter()
274            .filter(|entry| entry.status == ActorPresenceStatus::Active)
275            .collect())
276    }
277
278    fn create_generated_entry_with<F, G>(
279        &self,
280        mut generate_id: G,
281        mut build_entry: F,
282    ) -> Result<ActorPresence>
283    where
284        F: FnMut(&str) -> Result<ActorPresence>,
285        G: FnMut() -> String,
286    {
287        let _lock = self.write_lock()?;
288
289        loop {
290            let session_id = generate_id();
291            let path = self.entry_path(&session_id)?;
292            if path.exists() {
293                continue;
294            }
295
296            let entry = build_entry(&session_id)?;
297            self.write_entry_file(&entry)?;
298            return Ok(entry);
299        }
300    }
301
302    /// Create and persist a new agent entry with a unique generated session ID.
303    pub fn create_generated_entry<F>(&self, build_entry: F) -> Result<ActorPresence>
304    where
305        F: FnMut(&str) -> Result<ActorPresence>,
306    {
307        self.create_generated_entry_with(generate_actor_session_id, build_entry)
308    }
309
310    /// Persist an agent entry.
311    ///
312    /// Atomic write: uses write-to-temp-then-rename so a crash mid-write
313    /// never leaves the TOML file truncated or partially written.
314    pub fn save(&self, entry: &ActorPresence) -> Result<()> {
315        let _lock = self.write_lock()?;
316        self.write_entry_file(entry)
317    }
318
319    /// Load a single agent entry by session ID.
320    pub fn load(&self, session_id: &str) -> Result<Option<ActorPresence>> {
321        let path = self.entry_path(session_id)?;
322        let Some(entry) = self.load_entry_from_path(&path)? else {
323            return Ok(None);
324        };
325
326        if self.is_stale_terminal_entry(&entry) {
327            let _lock = self.write_lock()?;
328            if let Some(latest) = self.load_entry_from_path(&path)?
329                && self.is_stale_terminal_entry(&latest)
330            {
331                self.prune_stale_entry_path(&path)?;
332                return Ok(None);
333            }
334        }
335
336        Ok(Some(entry))
337    }
338
339    /// List all agent entries, most-recently-started first.
340    pub fn list(&self) -> Result<Vec<ActorPresence>> {
341        if !self.presence_dir.exists() {
342            return Ok(Vec::new());
343        }
344
345        let mut stale_paths = Vec::new();
346        let mut entries = Vec::new();
347        for dir_entry in std::fs::read_dir(&self.presence_dir)? {
348            let dir_entry = dir_entry?;
349            let path = dir_entry.path();
350            if path.extension().map(|e| e == "toml").unwrap_or(false) {
351                let content = std::fs::read_to_string(&path)?;
352                let entry = toml::from_str::<ActorPresence>(&content).map_err(|err| {
353                    HeddleError::Config(format!(
354                        "failed to parse agent registry entry '{}': {err}",
355                        path.display()
356                    ))
357                })?;
358                if self.is_stale_terminal_entry(&entry) {
359                    stale_paths.push(path);
360                } else {
361                    entries.push(entry);
362                }
363            }
364        }
365
366        if !stale_paths.is_empty() {
367            let _lock = self.write_lock()?;
368            for path in stale_paths {
369                if let Some(entry) = self.load_entry_from_path(&path)?
370                    && self.is_stale_terminal_entry(&entry)
371                {
372                    self.prune_stale_entry_path(&path)?;
373                }
374            }
375        }
376
377        entries.sort_by_key(|a| std::cmp::Reverse(a.started_at));
378        Ok(entries)
379    }
380
381    /// List every persisted entry without pruning stale terminal records.
382    ///
383    /// Cleanup previews use this read-only view so residue detection cannot
384    /// mutate actor-presence storage as a side effect.
385    pub fn list_without_pruning(&self) -> Result<Vec<ActorPresence>> {
386        if !self.presence_dir.exists() {
387            return Ok(Vec::new());
388        }
389
390        let mut entries = Vec::new();
391        for dir_entry in std::fs::read_dir(&self.presence_dir)? {
392            let path = dir_entry?.path();
393            if path
394                .extension()
395                .is_some_and(|extension| extension == "toml")
396            {
397                let content = std::fs::read_to_string(&path)?;
398                entries.push(toml::from_str::<ActorPresence>(&content).map_err(|err| {
399                    HeddleError::Config(format!(
400                        "failed to parse agent registry entry '{}': {err}",
401                        path.display()
402                    ))
403                })?);
404            }
405        }
406        entries.sort_by_key(|entry| std::cmp::Reverse(entry.started_at));
407        Ok(entries)
408    }
409
410    /// Update the status of an agent entry in place.
411    pub fn update_status(&self, session_id: &str, status: ActorPresenceStatus) -> Result<()> {
412        let _lock = self.write_lock()?;
413        let path = self.entry_path(session_id)?;
414        if let Some(mut entry) = self.load_entry_from_path(&path)? {
415            entry.status = status;
416            entry.completed_at = match entry.status {
417                ActorPresenceStatus::Active => None,
418                ActorPresenceStatus::Abandoned
419                | ActorPresenceStatus::Complete
420                | ActorPresenceStatus::Merged => Some(Utc::now()),
421            };
422            self.write_entry_file(&entry)?;
423        }
424        Ok(())
425    }
426
427    /// Mutate an existing agent entry under the registry write lock.
428    pub fn update_entry<F>(&self, session_id: &str, mut update: F) -> Result<Option<ActorPresence>>
429    where
430        F: FnMut(&mut ActorPresence),
431    {
432        let _lock = self.write_lock()?;
433        let path = self.entry_path(session_id)?;
434        let Some(mut entry) = self.load_entry_from_path(&path)? else {
435            return Ok(None);
436        };
437        update(&mut entry);
438        self.write_entry_file(&entry)?;
439        Ok(Some(entry))
440    }
441
442    /// Under one registry write lock, reuse a matching active entry if one
443    /// exists; otherwise create a new generated entry.
444    pub fn find_or_create_active_entry<FMatch, FUpdate, FBuild>(
445        &self,
446        mut matches: FMatch,
447        mut update_existing: FUpdate,
448        mut build_entry: FBuild,
449    ) -> Result<(ActorPresence, bool)>
450    where
451        FMatch: FnMut(&ActorPresence) -> bool,
452        FUpdate: FnMut(&mut ActorPresence),
453        FBuild: FnMut(&str) -> Result<ActorPresence>,
454    {
455        let _lock = self.write_lock()?;
456        crate::fs_atomic::create_dir_all_durable(&self.presence_dir)?;
457
458        for dir_entry in std::fs::read_dir(&self.presence_dir)? {
459            let dir_entry = dir_entry?;
460            let path = dir_entry.path();
461            if !path.extension().map(|e| e == "toml").unwrap_or(false) {
462                continue;
463            }
464            let Some(mut entry) = self.load_entry_from_path(&path)? else {
465                continue;
466            };
467            if self.is_stale_terminal_entry(&entry) {
468                self.prune_stale_entry_path(&path)?;
469                continue;
470            }
471            if entry.status == ActorPresenceStatus::Active && matches(&entry) {
472                update_existing(&mut entry);
473                self.write_entry_file(&entry)?;
474                return Ok((entry, false));
475            }
476        }
477
478        loop {
479            let session_id = generate_actor_session_id();
480            let path = self.entry_path(&session_id)?;
481            if path.exists() {
482                continue;
483            }
484
485            let entry = build_entry(&session_id)?;
486            self.write_entry_file(&entry)?;
487            return Ok((entry, true));
488        }
489    }
490
491    /// Find the active session whose visible or private execution root matches
492    /// the given worktree root.
493    pub fn find_active_by_path(&self, worktree_root: &Path) -> Result<Option<ActorPresence>> {
494        let canonical = worktree_root
495            .canonicalize()
496            .unwrap_or_else(|_| worktree_root.to_path_buf());
497        let entries = self.active_entries()?;
498        Ok(entries
499            .into_iter()
500            .find(|entry| entry_matches_root(entry, &canonical)))
501    }
502
503    /// Find the active registry entry associated with the given Heddle session ID.
504    pub fn find_active_by_heddle_session_id(
505        &self,
506        heddle_session_id: &str,
507    ) -> Result<Option<ActorPresence>> {
508        let entries = self.active_entries()?;
509        Ok(entries
510            .into_iter()
511            .find(|entry| entry.heddle_session_id.as_deref() == Some(heddle_session_id)))
512    }
513
514    /// Find the active registry entry associated with a stable harness-side
515    /// client instance identifier.
516    pub fn find_active_by_client_instance_id(
517        &self,
518        client_instance_id: &str,
519    ) -> Result<Option<ActorPresence>> {
520        let entries = self.active_entries()?;
521        Ok(entries
522            .into_iter()
523            .find(|entry| entry.client_instance_id.as_deref() == Some(client_instance_id)))
524    }
525
526    /// Find the active registry entry associated with a harness-native actor key.
527    pub fn find_active_by_native_actor_key(
528        &self,
529        native_actor_key: &str,
530    ) -> Result<Option<ActorPresence>> {
531        let entries = self.active_entries()?;
532        Ok(entries
533            .into_iter()
534            .find(|entry| entry.native_actor_key.as_deref() == Some(native_actor_key)))
535    }
536
537    /// Return this actor's native parent chain, ordered root to leaf.
538    ///
539    /// The lookup intentionally follows harness-native actor keys rather than
540    /// thread names: subagents may work in lightweight directories or forked
541    /// threads, but the native parent key is the stable "who spawned whom"
542    /// edge that preserves Human -> agent -> agent attribution.
543    pub fn actor_chain_for_session(&self, session_id: &str) -> Result<Vec<ActorChainNode>> {
544        let entries = self.current_entries()?;
545        let by_session: HashMap<&str, &ActorPresence> = entries
546            .iter()
547            .map(|entry| (entry.session_id.as_str(), entry))
548            .collect();
549        let by_native_key: HashMap<&str, &ActorPresence> = entries
550            .iter()
551            .filter_map(|entry| entry.native_actor_key.as_deref().map(|key| (key, entry)))
552            .collect();
553
554        let Some(mut current) = by_session.get(session_id).copied() else {
555            return Ok(Vec::new());
556        };
557        let mut leaf_to_root = vec![ActorChainNode::from(current)];
558        let mut seen = HashSet::from([current.session_id.as_str()]);
559
560        while let Some(parent_key) = current.native_parent_actor_key.as_deref() {
561            let Some(parent) = by_native_key.get(parent_key).copied() else {
562                break;
563            };
564            if !seen.insert(parent.session_id.as_str()) {
565                break;
566            }
567            leaf_to_root.push(ActorChainNode::from(parent));
568            current = parent;
569        }
570
571        leaf_to_root.reverse();
572        Ok(leaf_to_root)
573    }
574
575    /// Find the active registry entry associated with a harness-native instance
576    /// key inside the given worktree root.
577    pub fn find_active_by_native_instance_key_at_path(
578        &self,
579        native_instance_key: &str,
580        worktree_root: &Path,
581    ) -> Result<Option<ActorPresence>> {
582        let canonical = worktree_root
583            .canonicalize()
584            .unwrap_or_else(|_| worktree_root.to_path_buf());
585        let entries = self.active_entries()?;
586        Ok(entries.into_iter().find(|entry| {
587            entry.native_instance_key.as_deref() == Some(native_instance_key)
588                && entry_matches_root(entry, &canonical)
589        }))
590    }
591
592    /// Append a context query to an active session's log.
593    ///
594    /// Best-effort: silently ignored if the session no longer exists or has completed.
595    pub fn log_context_query(&self, session_id: &str, query: ContextQueryEntry) -> Result<()> {
596        let _lock = self.write_lock()?;
597        let path = self.entry_path(session_id)?;
598        if let Some(mut entry) = self.load_entry_from_path(&path)?
599            && entry.status == ActorPresenceStatus::Active
600        {
601            entry.context_queries.push(query);
602            self.write_entry_file(&entry)?;
603        }
604        Ok(())
605    }
606
607    /// Delete an agent entry.
608    pub fn delete(&self, session_id: &str) -> Result<()> {
609        let path = self.entry_path(session_id)?;
610        if path.exists() {
611            std::fs::remove_file(path)?;
612        }
613        Ok(())
614    }
615}
616
617/// Generate a unique agent session identifier.
618///
619/// Uses 12 random bytes (96 bits) encoded as lowercase base32, giving
620/// a birthday-paradox collision probability of < 10⁻²⁰ at a million sessions.
621pub fn generate_actor_session_id() -> String {
622    let random_bytes: [u8; 12] = rand::random();
623    format!(
624        "agent-{}",
625        base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &random_bytes).to_lowercase()
626    )
627}
628
629fn entry_matches_root(entry: &ActorPresence, canonical: &Path) -> bool {
630    entry
631        .path
632        .as_ref()
633        .map(|p| p.canonicalize().unwrap_or_else(|_| p.clone()) == canonical)
634        .unwrap_or(false)
635}
636
637#[cfg(test)]
638mod tests {
639    use tempfile::TempDir;
640
641    use super::*;
642
643    fn presence(session_id: &str, status: ActorPresenceStatus) -> ActorPresence {
644        ActorPresence {
645            session_id: session_id.to_string(),
646            client_instance_id: None,
647            native_actor_key: None,
648            native_parent_actor_key: None,
649            native_instance_key: None,
650            heddle_session_id: None,
651            thread_id: None,
652            thread: "feature/test".to_string(),
653            anchor_state: Some("hd-state".to_string()),
654            anchor_root: Some("root".to_string()),
655            path: None,
656            base_state: "hd-state".to_string(),
657            started_at: Utc::now(),
658            provider: Some("openai".to_string()),
659            model: Some("gpt-5".to_string()),
660            harness: Some("codex".to_string()),
661            thinking_level: None,
662            usage_summary: AgentUsageSummary::default(),
663            last_progress_at: None,
664            report_flush_state: None,
665            attach_reason: Some("test".to_string()),
666            task_assignment_id: None,
667            attach_precedence: vec!["explicit".to_string()],
668            winning_attach_rule: Some("explicit".to_string()),
669            probe_source: None,
670            probe_confidence: None,
671            status,
672            completed_at: None,
673            context_queries: vec![],
674        }
675    }
676
677    #[test]
678    fn active_presence_is_independent_of_writer_liveness() {
679        let temp = TempDir::new().unwrap();
680        let store = ActorPresenceStore::new(temp.path());
681        store
682            .save(&presence("agent-one", ActorPresenceStatus::Active))
683            .unwrap();
684
685        let active = store.active_entries().unwrap();
686        assert_eq!(active.len(), 1);
687        assert_eq!(active[0].session_id, "agent-one");
688    }
689
690    #[test]
691    fn active_presence_can_be_reused_by_native_identity() {
692        let temp = TempDir::new().unwrap();
693        let store = ActorPresenceStore::new(temp.path());
694        let (first, created) = store
695            .find_or_create_active_entry(
696                |_| false,
697                |_| {},
698                |session_id| {
699                    let mut entry = presence(session_id, ActorPresenceStatus::Active);
700                    entry.native_actor_key = Some("codex:thread:one".to_string());
701                    Ok(entry)
702                },
703            )
704            .unwrap();
705        assert!(created);
706
707        let (second, created) = store
708            .find_or_create_active_entry(
709                |entry| entry.native_actor_key.as_deref() == Some("codex:thread:one"),
710                |_| {},
711                |_| panic!("matching presence should be reused"),
712            )
713            .unwrap();
714        assert!(!created);
715        assert_eq!(first.session_id, second.session_id);
716    }
717
718    #[test]
719    fn terminal_presence_is_retained_for_recent_provenance() {
720        let temp = TempDir::new().unwrap();
721        let store = ActorPresenceStore::new(temp.path());
722        let mut complete = presence("agent-done", ActorPresenceStatus::Complete);
723        complete.completed_at = Some(Utc::now());
724        store.save(&complete).unwrap();
725
726        let loaded = store.load("agent-done").unwrap().unwrap();
727        assert_eq!(loaded.status, ActorPresenceStatus::Complete);
728    }
729}