1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ContextQueryEntry {
23 pub path: String,
25 pub scope: Option<String>,
27 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum ActorPresenceStatus {
49 Active,
51 Abandoned,
53 Complete,
55 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#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct ActorPresence {
73 pub session_id: String,
75 #[serde(default)]
78 pub client_instance_id: Option<String>,
79 #[serde(default)]
81 pub native_actor_key: Option<String>,
82 #[serde(default)]
84 pub native_parent_actor_key: Option<String>,
85 #[serde(default)]
87 pub native_instance_key: Option<String>,
88 #[serde(default)]
91 pub heddle_session_id: Option<String>,
92 #[serde(default)]
94 pub thread_id: Option<String>,
95 pub thread: String,
97 #[serde(default)]
99 pub anchor_state: Option<String>,
100 #[serde(default)]
102 pub anchor_root: Option<String>,
103 #[serde(default)]
105 pub path: Option<PathBuf>,
106 pub base_state: String,
108 pub started_at: DateTime<Utc>,
110 pub provider: Option<String>,
112 pub model: Option<String>,
114 #[serde(default)]
116 pub harness: Option<String>,
117 #[serde(default)]
119 pub thinking_level: Option<String>,
120 #[serde(default)]
122 pub usage_summary: AgentUsageSummary,
123 #[serde(default)]
125 pub last_progress_at: Option<DateTime<Utc>>,
126 #[serde(default)]
128 pub report_flush_state: Option<String>,
129 #[serde(default)]
132 pub attach_reason: Option<String>,
133 #[serde(default)]
135 pub task_assignment_id: Option<String>,
136 #[serde(default)]
138 pub attach_precedence: Vec<String>,
139 #[serde(default)]
141 pub winning_attach_rule: Option<String>,
142 #[serde(default)]
144 pub probe_source: Option<String>,
145 #[serde(default)]
147 pub probe_confidence: Option<f32>,
148 pub status: ActorPresenceStatus,
150 #[serde(default)]
152 pub completed_at: Option<DateTime<Utc>>,
153 #[serde(default)]
156 pub context_queries: Vec<ContextQueryEntry>,
157}
158
159#[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
192pub struct ActorPresenceStore {
194 presence_dir: PathBuf,
195}
196
197impl ActorPresenceStore {
198 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 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 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 pub fn save(&self, entry: &ActorPresence) -> Result<()> {
315 let _lock = self.write_lock()?;
316 self.write_entry_file(entry)
317 }
318
319 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 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 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 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 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 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 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 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 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 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 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 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 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 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
617pub 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}