Skip to main content

ralph_core/
loop_registry.rs

1//! Loop registry for tracking active Ralph loops across workspaces.
2//!
3//! The registry maintains a list of running loops with their metadata,
4//! providing discovery and coordination capabilities for multi-loop scenarios.
5//!
6//! # Design
7//!
8//! - **JSON persistence**: Single JSON file at `.ralph/loops.json`
9//! - **File locking**: Uses `flock()` for concurrent access safety
10//! - **PID-based stale detection**: Automatically cleans up entries for dead processes
11//!
12//! # Example
13//!
14//! ```no_run
15//! use ralph_core::loop_registry::{LoopRegistry, LoopEntry};
16//!
17//! fn main() -> Result<(), Box<dyn std::error::Error>> {
18//!     let registry = LoopRegistry::new(".");
19//!
20//!     // Register this loop
21//!     let entry = LoopEntry::new("implement auth", Some("/path/to/worktree"));
22//!     let id = registry.register(entry)?;
23//!
24//!     // List all active loops
25//!     for loop_entry in registry.list()? {
26//!         println!("Loop {}: {}", loop_entry.id, loop_entry.prompt);
27//!     }
28//!
29//!     // Deregister when done
30//!     registry.deregister(&id)?;
31//!     Ok(())
32//! }
33//! ```
34
35use chrono::{DateTime, Utc};
36use serde::{Deserialize, Serialize};
37use std::fs::{self, File, OpenOptions};
38use std::io::{self, Read, Seek, SeekFrom, Write};
39use std::path::{Path, PathBuf};
40use std::process;
41
42/// Metadata for a registered loop.
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
44pub struct LoopEntry {
45    /// Unique loop ID: loop-{unix_timestamp}-{4_hex_chars}
46    pub id: String,
47
48    /// Process ID of the loop.
49    pub pid: u32,
50
51    /// When the loop was started.
52    pub started: DateTime<Utc>,
53
54    /// The prompt/task being executed.
55    pub prompt: String,
56
57    /// Path to the worktree (None if running in main workspace).
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub worktree_path: Option<String>,
60
61    /// The workspace root where the loop is running.
62    pub workspace: String,
63}
64
65impl LoopEntry {
66    /// Creates a new loop entry for the current process.
67    pub fn new(prompt: impl Into<String>, worktree_path: Option<impl Into<String>>) -> Self {
68        Self {
69            id: Self::generate_id(),
70            pid: process::id(),
71            started: Utc::now(),
72            prompt: prompt.into(),
73            worktree_path: worktree_path.map(Into::into),
74            workspace: std::env::current_dir()
75                .map(|p| p.display().to_string())
76                .unwrap_or_default(),
77        }
78    }
79
80    /// Creates a new loop entry with a specific workspace.
81    pub fn with_workspace(
82        prompt: impl Into<String>,
83        worktree_path: Option<impl Into<String>>,
84        workspace: impl Into<String>,
85    ) -> Self {
86        Self {
87            id: Self::generate_id(),
88            pid: process::id(),
89            started: Utc::now(),
90            prompt: prompt.into(),
91            worktree_path: worktree_path.map(Into::into),
92            workspace: workspace.into(),
93        }
94    }
95
96    /// Creates a new loop entry with a specific ID.
97    ///
98    /// Use this when you need the loop ID to match other identifiers
99    /// (e.g., worktree name, branch name).
100    pub fn with_id(
101        id: impl Into<String>,
102        prompt: impl Into<String>,
103        worktree_path: Option<impl Into<String>>,
104        workspace: impl Into<String>,
105    ) -> Self {
106        Self {
107            id: id.into(),
108            pid: process::id(),
109            started: Utc::now(),
110            prompt: prompt.into(),
111            worktree_path: worktree_path.map(Into::into),
112            workspace: workspace.into(),
113        }
114    }
115
116    /// Generates a unique loop ID: loop-{timestamp}-{hex_suffix}
117    fn generate_id() -> String {
118        use std::time::{SystemTime, UNIX_EPOCH};
119        let duration = SystemTime::now()
120            .duration_since(UNIX_EPOCH)
121            .expect("Time went backwards");
122        let timestamp = duration.as_secs();
123        let hex_suffix = format!("{:04x}", duration.subsec_micros() % 0x10000);
124        format!("loop-{}-{}", timestamp, hex_suffix)
125    }
126
127    /// Checks if the process for this loop is still running.
128    ///
129    /// For worktree loops, also verifies the worktree directory still exists.
130    /// A process whose worktree has been removed externally is considered dead
131    /// (zombie) even if the PID is still alive.
132    #[cfg(unix)]
133    pub fn is_alive(&self) -> bool {
134        use nix::sys::signal::kill;
135        use nix::unistd::Pid;
136
137        // Signal 0 (None) checks if process exists without sending a signal
138        let pid_alive = kill(Pid::from_raw(self.pid as i32), None)
139            .map(|_| true)
140            .unwrap_or(false);
141
142        if !pid_alive {
143            return false;
144        }
145
146        // If this is a worktree loop, verify the directory still exists
147        if let Some(ref wt_path) = self.worktree_path {
148            return std::path::Path::new(wt_path).is_dir();
149        }
150
151        true
152    }
153
154    #[cfg(not(unix))]
155    pub fn is_alive(&self) -> bool {
156        // On non-Unix platforms, check worktree existence at minimum
157        if let Some(ref wt_path) = self.worktree_path {
158            return std::path::Path::new(wt_path).is_dir();
159        }
160        true
161    }
162
163    /// Checks if the PID is alive (regardless of worktree state).
164    ///
165    /// Use this when you need to know if the process itself is running,
166    /// e.g. to decide whether to send a signal.
167    #[cfg(unix)]
168    pub fn is_pid_alive(&self) -> bool {
169        use nix::sys::signal::kill;
170        use nix::unistd::Pid;
171
172        kill(Pid::from_raw(self.pid as i32), None)
173            .map(|_| true)
174            .unwrap_or(false)
175    }
176
177    #[cfg(not(unix))]
178    pub fn is_pid_alive(&self) -> bool {
179        true
180    }
181}
182
183/// The persisted registry data.
184#[derive(Debug, Default, Clone, Serialize, Deserialize)]
185struct RegistryData {
186    loops: Vec<LoopEntry>,
187}
188
189/// Errors that can occur during registry operations.
190#[derive(Debug, thiserror::Error)]
191pub enum RegistryError {
192    /// IO error during registry operations.
193    #[error("IO error: {0}")]
194    Io(#[from] io::Error),
195
196    /// Failed to parse registry data.
197    #[error("Failed to parse registry: {0}")]
198    ParseError(String),
199
200    /// Loop entry not found.
201    #[error("Loop not found: {0}")]
202    NotFound(String),
203
204    /// Platform not supported.
205    #[error("File locking not supported on this platform")]
206    UnsupportedPlatform,
207}
208
209/// Registry for tracking active Ralph loops.
210///
211/// Provides thread-safe registration and discovery of running loops.
212pub struct LoopRegistry {
213    /// Path to the registry file.
214    registry_path: PathBuf,
215}
216
217impl LoopRegistry {
218    /// The relative path to the registry file within the workspace.
219    pub const REGISTRY_FILE: &'static str = ".ralph/loops.json";
220
221    /// Creates a new registry instance for the given workspace.
222    pub fn new(workspace_root: impl AsRef<Path>) -> Self {
223        Self {
224            registry_path: workspace_root.as_ref().join(Self::REGISTRY_FILE),
225        }
226    }
227
228    /// Registers a new loop entry.
229    ///
230    /// Returns the entry's ID for later deregistration.
231    pub fn register(&self, entry: LoopEntry) -> Result<String, RegistryError> {
232        let id = entry.id.clone();
233        self.with_lock(|data| {
234            // Remove any existing entry with the same PID (stale from crash)
235            data.loops.retain(|e| e.pid != entry.pid);
236            data.loops.push(entry);
237        })?;
238        Ok(id)
239    }
240
241    /// Deregisters a loop by ID.
242    pub fn deregister(&self, id: &str) -> Result<(), RegistryError> {
243        let mut found = false;
244        self.with_lock(|data| {
245            let original_len = data.loops.len();
246            data.loops.retain(|e| e.id != id);
247            found = data.loops.len() != original_len;
248        })?;
249        if !found {
250            return Err(RegistryError::NotFound(id.to_string()));
251        }
252        Ok(())
253    }
254
255    /// Gets a loop entry by ID.
256    pub fn get(&self, id: &str) -> Result<Option<LoopEntry>, RegistryError> {
257        let mut result = None;
258        self.with_lock(|data| {
259            result = data.loops.iter().find(|e| e.id == id).cloned();
260        })?;
261        Ok(result)
262    }
263
264    /// Lists all active loops (after cleaning stale entries).
265    pub fn list(&self) -> Result<Vec<LoopEntry>, RegistryError> {
266        let mut result = Vec::new();
267        self.with_lock(|data| {
268            result = data.loops.clone();
269        })?;
270        Ok(result)
271    }
272
273    /// Cleans stale entries (dead PIDs) and returns the number removed.
274    pub fn clean_stale(&self) -> Result<usize, RegistryError> {
275        let mut removed = 0;
276        self.with_lock(|data| {
277            let original_len = data.loops.len();
278            data.loops.retain(|e| e.is_alive());
279            removed = original_len - data.loops.len();
280        })?;
281        Ok(removed)
282    }
283
284    /// Deregisters all entries for the current process.
285    ///
286    /// This is useful for cleanup on termination, since each process
287    /// can only have one active loop entry.
288    pub fn deregister_current_process(&self) -> Result<bool, RegistryError> {
289        let pid = std::process::id();
290        let mut found = false;
291        self.with_lock(|data| {
292            let original_len = data.loops.len();
293            data.loops.retain(|e| e.pid != pid);
294            found = data.loops.len() != original_len;
295        })?;
296        Ok(found)
297    }
298
299    /// Executes an operation with the registry file locked.
300    #[cfg(unix)]
301    fn with_lock<F>(&self, f: F) -> Result<(), RegistryError>
302    where
303        F: FnOnce(&mut RegistryData),
304    {
305        use nix::fcntl::{Flock, FlockArg};
306
307        // Ensure .ralph directory exists
308        if let Some(parent) = self.registry_path.parent() {
309            fs::create_dir_all(parent)?;
310        }
311
312        // Open or create the file
313        let file = OpenOptions::new()
314            .read(true)
315            .write(true)
316            .create(true)
317            .truncate(false)
318            .open(&self.registry_path)?;
319
320        // Acquire exclusive lock (blocking)
321        let flock = Flock::lock(file, FlockArg::LockExclusive).map_err(|(_, errno)| {
322            RegistryError::Io(io::Error::new(
323                io::ErrorKind::Other,
324                format!("flock failed: {}", errno),
325            ))
326        })?;
327
328        // Read existing data using the locked file
329        let mut data = self.read_data_from_file(&flock)?;
330
331        // Clean stale entries before any operation (dead PIDs only).
332        //
333        // Keep zombie worktree entries (PID alive, worktree gone) so callers can
334        // still discover and explicitly stop/clean them.
335        data.loops.retain(|e| e.is_pid_alive());
336
337        // Execute the user function
338        f(&mut data);
339
340        // Write back the data
341        self.write_data_to_file(&flock, &data)?;
342
343        Ok(())
344    }
345
346    #[cfg(not(unix))]
347    fn with_lock<F>(&self, _f: F) -> Result<(), RegistryError>
348    where
349        F: FnOnce(&mut RegistryData),
350    {
351        Err(RegistryError::UnsupportedPlatform)
352    }
353
354    /// Reads registry data from a locked file.
355    #[cfg(unix)]
356    fn read_data_from_file(
357        &self,
358        flock: &nix::fcntl::Flock<File>,
359    ) -> Result<RegistryData, RegistryError> {
360        use std::os::fd::AsFd;
361
362        // Get a clone of the underlying file via BorrowedFd
363        let borrowed_fd = flock.as_fd();
364        let owned_fd = borrowed_fd.try_clone_to_owned()?;
365        let mut file: File = owned_fd.into();
366
367        file.seek(SeekFrom::Start(0))?;
368
369        let mut contents = String::new();
370        file.read_to_string(&mut contents)?;
371
372        if contents.trim().is_empty() {
373            return Ok(RegistryData::default());
374        }
375
376        serde_json::from_str(&contents).map_err(|e| RegistryError::ParseError(e.to_string()))
377    }
378
379    /// Writes registry data to a locked file.
380    #[cfg(unix)]
381    fn write_data_to_file(
382        &self,
383        flock: &nix::fcntl::Flock<File>,
384        data: &RegistryData,
385    ) -> Result<(), RegistryError> {
386        use std::os::fd::AsFd;
387
388        // Get a clone of the underlying file via BorrowedFd
389        let borrowed_fd = flock.as_fd();
390        let owned_fd = borrowed_fd.try_clone_to_owned()?;
391        let mut file: File = owned_fd.into();
392
393        file.set_len(0)?;
394        file.seek(SeekFrom::Start(0))?;
395
396        let json = serde_json::to_string_pretty(data)
397            .map_err(|e| RegistryError::ParseError(e.to_string()))?;
398
399        file.write_all(json.as_bytes())?;
400        file.sync_all()?;
401
402        Ok(())
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use tempfile::TempDir;
410
411    #[test]
412    fn test_loop_entry_creation() {
413        let entry = LoopEntry::new("test prompt", None::<String>);
414        assert!(entry.id.starts_with("loop-"));
415        assert_eq!(entry.pid, process::id());
416        assert_eq!(entry.prompt, "test prompt");
417        assert!(entry.worktree_path.is_none());
418    }
419
420    #[test]
421    fn test_loop_entry_with_worktree() {
422        let entry = LoopEntry::new("test prompt", Some("/path/to/worktree"));
423        assert_eq!(entry.worktree_path, Some("/path/to/worktree".to_string()));
424    }
425
426    #[test]
427    fn test_loop_entry_id_format() {
428        let entry = LoopEntry::new("test", None::<String>);
429        let parts: Vec<&str> = entry.id.split('-').collect();
430        assert_eq!(parts.len(), 3);
431        assert_eq!(parts[0], "loop");
432    }
433
434    #[test]
435    fn test_loop_entry_is_alive() {
436        let entry = LoopEntry::new("test", None::<String>);
437        // Current process should be alive
438        assert!(entry.is_alive());
439    }
440
441    #[test]
442    fn test_loop_entry_with_id() {
443        let entry = LoopEntry::with_id(
444            "bright-maple",
445            "test prompt",
446            Some("/path/to/worktree"),
447            "/workspace",
448        );
449        assert_eq!(entry.id, "bright-maple");
450        assert_eq!(entry.pid, process::id());
451        assert_eq!(entry.prompt, "test prompt");
452        assert_eq!(entry.worktree_path, Some("/path/to/worktree".to_string()));
453        assert_eq!(entry.workspace, "/workspace");
454    }
455
456    #[test]
457    fn test_registry_creates_file() {
458        let temp_dir = TempDir::new().unwrap();
459        let registry_path = temp_dir.path().join(".ralph/loops.json");
460
461        assert!(!registry_path.exists());
462
463        let registry = LoopRegistry::new(temp_dir.path());
464        let entry = LoopEntry::new("test prompt", None::<String>);
465        registry.register(entry).unwrap();
466
467        assert!(registry_path.exists());
468    }
469
470    #[test]
471    fn test_registry_register_and_list() {
472        let temp_dir = TempDir::new().unwrap();
473        let registry = LoopRegistry::new(temp_dir.path());
474
475        let entry = LoopEntry::new("test prompt", None::<String>);
476        let id = entry.id.clone();
477
478        registry.register(entry).unwrap();
479
480        let loops = registry.list().unwrap();
481        assert_eq!(loops.len(), 1);
482        assert_eq!(loops[0].id, id);
483        assert_eq!(loops[0].prompt, "test prompt");
484    }
485
486    #[test]
487    fn test_registry_get() {
488        let temp_dir = TempDir::new().unwrap();
489        let registry = LoopRegistry::new(temp_dir.path());
490
491        let entry = LoopEntry::new("test prompt", None::<String>);
492        let id = entry.id.clone();
493
494        registry.register(entry).unwrap();
495
496        let retrieved = registry.get(&id).unwrap();
497        assert!(retrieved.is_some());
498        assert_eq!(retrieved.unwrap().prompt, "test prompt");
499    }
500
501    #[test]
502    fn test_registry_get_nonexistent() {
503        let temp_dir = TempDir::new().unwrap();
504        let registry = LoopRegistry::new(temp_dir.path());
505
506        // Need to create the file first
507        let entry = LoopEntry::new("dummy", None::<String>);
508        let id = entry.id.clone();
509        registry.register(entry).unwrap();
510        registry.deregister(&id).unwrap();
511
512        let retrieved = registry.get("nonexistent").unwrap();
513        assert!(retrieved.is_none());
514    }
515
516    #[test]
517    fn test_registry_deregister() {
518        let temp_dir = TempDir::new().unwrap();
519        let registry = LoopRegistry::new(temp_dir.path());
520
521        let entry = LoopEntry::new("test prompt", None::<String>);
522        let id = entry.id.clone();
523
524        registry.register(entry).unwrap();
525        assert_eq!(registry.list().unwrap().len(), 1);
526
527        registry.deregister(&id).unwrap();
528        assert_eq!(registry.list().unwrap().len(), 0);
529    }
530
531    #[test]
532    fn test_registry_deregister_nonexistent() {
533        let temp_dir = TempDir::new().unwrap();
534        let registry = LoopRegistry::new(temp_dir.path());
535
536        // Register and deregister to create the file
537        let entry = LoopEntry::new("dummy", None::<String>);
538        let id = entry.id.clone();
539        registry.register(entry).unwrap();
540        registry.deregister(&id).unwrap();
541
542        let result = registry.deregister("nonexistent");
543        assert!(matches!(result, Err(RegistryError::NotFound(_))));
544    }
545
546    #[test]
547    fn test_registry_same_pid_replaces() {
548        // Same PID entries replace each other (prevents stale entries from crashes)
549        let temp_dir = TempDir::new().unwrap();
550        let registry = LoopRegistry::new(temp_dir.path());
551
552        // Create a real worktree directory so is_alive() doesn't treat it as zombie
553        let wt_dir = temp_dir.path().join("worktree");
554        fs::create_dir_all(&wt_dir).unwrap();
555
556        let entry1 = LoopEntry::new("prompt 1", None::<String>);
557        let entry2 = LoopEntry::new("prompt 2", Some(wt_dir.display().to_string()));
558
559        // Both entries have the same PID (current process)
560        assert_eq!(entry1.pid, entry2.pid);
561
562        registry.register(entry1).unwrap();
563        registry.register(entry2).unwrap();
564
565        // Second entry should replace first (same PID)
566        let loops = registry.list().unwrap();
567        assert_eq!(loops.len(), 1);
568        assert_eq!(loops[0].prompt, "prompt 2");
569    }
570
571    #[test]
572    fn test_registry_different_pids_coexist() {
573        // Entries with different PIDs should coexist
574        let temp_dir = TempDir::new().unwrap();
575        let registry = LoopRegistry::new(temp_dir.path());
576
577        // Create entry with current PID
578        let entry1 = LoopEntry::new("prompt 1", None::<String>);
579        let id1 = entry1.id.clone();
580        registry.register(entry1).unwrap();
581
582        // Manually create entry with different PID (simulating another process)
583        let mut entry2 = LoopEntry::new("prompt 2", Some("/worktree"));
584        entry2.pid = 99999; // Fake PID - won't exist so will be cleaned as stale
585        let id2 = entry2.id.clone();
586
587        // Write entry2 directly to file to bypass PID check
588        let registry_path = temp_dir.path().join(".ralph/loops.json");
589        let content = fs::read_to_string(&registry_path).unwrap();
590        let mut data: serde_json::Value = serde_json::from_str(&content).unwrap();
591        let loops = data["loops"].as_array_mut().unwrap();
592        loops.push(serde_json::json!({
593            "id": id2,
594            "pid": 99999,
595            "started": entry2.started,
596            "prompt": "prompt 2",
597            "worktree_path": "/worktree",
598            "workspace": entry2.workspace
599        }));
600        fs::write(&registry_path, serde_json::to_string_pretty(&data).unwrap()).unwrap();
601
602        // List should clean the stale entry (PID 99999 doesn't exist)
603        // But our current process entry should remain
604        let loops = registry.list().unwrap();
605        assert_eq!(loops.len(), 1);
606        assert_eq!(loops[0].id, id1);
607    }
608
609    #[test]
610    fn test_registry_replaces_same_pid() {
611        let temp_dir = TempDir::new().unwrap();
612        let registry = LoopRegistry::new(temp_dir.path());
613
614        // Register first entry
615        let entry1 = LoopEntry::new("prompt 1", None::<String>);
616        registry.register(entry1).unwrap();
617
618        // Register second entry with same PID (simulates restart)
619        let entry2 = LoopEntry::new("prompt 2", None::<String>);
620        registry.register(entry2).unwrap();
621
622        // Should only have one entry (the new one replaced the old)
623        let loops = registry.list().unwrap();
624        assert_eq!(loops.len(), 1);
625        assert_eq!(loops[0].prompt, "prompt 2");
626    }
627
628    #[test]
629    fn test_registry_persistence() {
630        let temp_dir = TempDir::new().unwrap();
631
632        let id = {
633            let registry = LoopRegistry::new(temp_dir.path());
634            let entry = LoopEntry::new("persistent prompt", None::<String>);
635            let id = entry.id.clone();
636            registry.register(entry).unwrap();
637            id
638        };
639
640        // Load again and verify data persisted
641        let registry = LoopRegistry::new(temp_dir.path());
642        let loops = registry.list().unwrap();
643        assert_eq!(loops.len(), 1);
644        assert_eq!(loops[0].id, id);
645        assert_eq!(loops[0].prompt, "persistent prompt");
646    }
647
648    #[test]
649    fn test_entry_serialization() {
650        let entry = LoopEntry::new("test prompt", Some("/worktree/path"));
651        let json = serde_json::to_string(&entry).unwrap();
652        let deserialized: LoopEntry = serde_json::from_str(&json).unwrap();
653
654        assert_eq!(deserialized.id, entry.id);
655        assert_eq!(deserialized.pid, entry.pid);
656        assert_eq!(deserialized.prompt, "test prompt");
657        assert_eq!(
658            deserialized.worktree_path,
659            Some("/worktree/path".to_string())
660        );
661    }
662
663    #[test]
664    fn test_entry_serialization_no_worktree() {
665        let entry = LoopEntry::new("test prompt", None::<String>);
666        let json = serde_json::to_string(&entry).unwrap();
667
668        // Verify worktree_path is not in JSON when None
669        assert!(!json.contains("worktree_path"));
670
671        let deserialized: LoopEntry = serde_json::from_str(&json).unwrap();
672        assert!(deserialized.worktree_path.is_none());
673    }
674
675    #[test]
676    fn test_deregister_current_process() {
677        let temp_dir = TempDir::new().unwrap();
678        let registry = LoopRegistry::new(temp_dir.path());
679
680        // Register an entry (uses current PID)
681        let entry = LoopEntry::new("test prompt", None::<String>);
682        registry.register(entry).unwrap();
683        assert_eq!(registry.list().unwrap().len(), 1);
684
685        // Deregister by current process
686        let found = registry.deregister_current_process().unwrap();
687        assert!(found);
688        assert_eq!(registry.list().unwrap().len(), 0);
689
690        // Second deregister should return false (nothing to remove)
691        let found = registry.deregister_current_process().unwrap();
692        assert!(!found);
693    }
694
695    #[test]
696    fn test_zombie_worktree_detected_as_dead() {
697        let temp_dir = TempDir::new().unwrap();
698
699        // Create a worktree directory, then remove it
700        let wt_dir = temp_dir.path().join("fake-worktree");
701        fs::create_dir_all(&wt_dir).unwrap();
702
703        let mut entry = LoopEntry::new("zombie test", Some(wt_dir.display().to_string()));
704        // Use current PID so is_pid_alive() returns true
705        entry.pid = process::id();
706
707        // Worktree exists → alive
708        assert!(entry.is_alive());
709        assert!(entry.is_pid_alive());
710
711        // Remove the worktree directory
712        fs::remove_dir_all(&wt_dir).unwrap();
713
714        // PID still alive, but worktree gone → zombie → is_alive() returns false
715        assert!(!entry.is_alive());
716        assert!(entry.is_pid_alive());
717    }
718
719    #[test]
720    fn test_no_worktree_entry_unaffected() {
721        // Entries without worktree_path should not be affected by the new check
722        let entry = LoopEntry::new("primary loop", None::<String>);
723        assert!(entry.is_alive());
724        assert!(entry.is_pid_alive());
725    }
726
727    #[test]
728    fn test_with_lock_keeps_zombie_until_explicit_cleanup() {
729        let temp_dir = TempDir::new().unwrap();
730        let registry = LoopRegistry::new(temp_dir.path());
731
732        // Create and register a live worktree loop entry.
733        let wt_dir = temp_dir.path().join("zombie-worktree");
734        fs::create_dir_all(&wt_dir).unwrap();
735
736        let entry = LoopEntry::new("zombie keep test", Some(wt_dir.display().to_string()));
737        let id = entry.id.clone();
738        registry.register(entry).unwrap();
739
740        // Remove worktree: entry becomes zombie (PID alive, worktree missing).
741        fs::remove_dir_all(&wt_dir).unwrap();
742
743        // Regular registry reads should keep the zombie entry available so CLI/API
744        // can report and clean it up.
745        let got = registry.get(&id).unwrap();
746        assert!(got.is_some());
747
748        // Explicit stale cleanup should remove zombie entries.
749        let removed = registry.clean_stale().unwrap();
750        assert_eq!(removed, 1);
751        assert!(registry.get(&id).unwrap().is_none());
752    }
753}