Skip to main content

agent_procs/daemon/
process_manager.rs

1use crate::daemon::log_writer::{self, OutputLine};
2use crate::daemon::port_allocator::PortAllocator;
3use crate::paths;
4use crate::protocol::{
5    ErrorCode, ProcessInfo, ProcessState, Response, RestartMode, RestartPolicy,
6    Stream as ProtoStream, WatchConfig, process_url,
7};
8use crate::session::IdCounter;
9use std::collections::HashMap;
10use std::process::Stdio;
11use std::sync::Arc;
12use std::sync::atomic::AtomicU64;
13use std::time::{Duration, Instant};
14use tokio::process::{Child, Command};
15use tokio::sync::broadcast;
16
17const DEFAULT_MAX_LOG_BYTES: u64 = 50 * 1024 * 1024; // 50MB
18
19/// Returns true if `name` is a valid DNS label: 1-63 lowercase alphanumeric/hyphen
20/// chars, not starting or ending with a hyphen.
21#[must_use]
22pub fn is_valid_dns_label(name: &str) -> bool {
23    if name.is_empty() || name.len() > 63 {
24        return false;
25    }
26    if name.starts_with('-') || name.ends_with('-') {
27        return false;
28    }
29    name.chars()
30        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
31}
32
33pub struct ManagedProcess {
34    pub name: String,
35    pub id: String,
36    pub command: String,
37    pub cwd: Option<String>,
38    pub env: HashMap<String, String>,
39    pub child: Option<Child>,
40    pub pid: u32,
41    pub started_at: Instant,
42    pub exit_code: Option<i32>,
43    pub port: Option<u16>,
44    // Supervisor fields
45    pub restart_policy: Option<RestartPolicy>,
46    pub watch_config: Option<WatchConfig>,
47    pub restart_count: u32,
48    pub manually_stopped: bool,
49    pub restart_pending: bool,
50    pub failed: bool,
51    pub supervisor_tx: Option<tokio::sync::mpsc::Sender<String>>,
52    pub capture_handles: Vec<tokio::task::JoinHandle<()>>,
53    pub watch_handle: Option<crate::daemon::watcher::WatchHandle>,
54}
55
56pub struct ProcessManager {
57    processes: HashMap<String, ManagedProcess>,
58    id_counter: IdCounter,
59    session: String,
60    pub output_tx: broadcast::Sender<OutputLine>,
61    port_allocator: PortAllocator,
62}
63
64impl ProcessManager {
65    pub fn new(session: &str) -> Self {
66        let (output_tx, _) = broadcast::channel(1024);
67        Self {
68            processes: HashMap::new(),
69            id_counter: IdCounter::new(),
70            session: session.to_string(),
71            output_tx,
72            port_allocator: PortAllocator::new(),
73        }
74    }
75
76    // Keep the async API used by the daemon actor; process setup is synchronous,
77    // while the spawned capture tasks perform the asynchronous I/O.
78    #[allow(unknown_lints)]
79    #[allow(unsafe_code, clippy::unused_async, clippy::unused_async_trait_impl)]
80    pub async fn spawn_process(
81        &mut self,
82        command: &str,
83        name: Option<String>,
84        cwd: Option<&str>,
85        env: Option<&HashMap<String, String>>,
86        port: Option<u16>,
87    ) -> Response {
88        let id = self.id_counter.next_id();
89        let name = name.unwrap_or_else(|| id.clone());
90
91        // Reject names that could cause path traversal in log files
92        if name.contains('/') || name.contains('\\') || name.contains("..") || name.contains('\0') {
93            return Response::Error {
94                code: ErrorCode::General,
95                message: format!("invalid process name: {}", name),
96            };
97        }
98
99        // When proxy is active, names must be valid DNS labels for subdomain routing
100        if self.port_allocator.is_proxy_enabled() && !is_valid_dns_label(&name) {
101            return Response::Error {
102                code: ErrorCode::General,
103                message: format!(
104                    "invalid process name for proxy: '{}' (must be lowercase alphanumeric/hyphens, max 63 chars)",
105                    name
106                ),
107            };
108        }
109
110        // Resolve the port: use explicit port, auto-assign if proxy is enabled, or None
111        let resolved_port = if let Some(p) = port {
112            Some(p)
113        } else if self.port_allocator.is_proxy_enabled() {
114            let assigned: std::collections::HashSet<u16> = self
115                .processes
116                .values()
117                .filter(|p| p.child.is_some())
118                .filter_map(|p| p.port)
119                .collect();
120            match self.port_allocator.auto_assign_port(&assigned) {
121                Ok(p) => Some(p),
122                Err(e) => {
123                    return Response::Error {
124                        code: ErrorCode::General,
125                        message: e.to_string(),
126                    };
127                }
128            }
129        } else {
130            None
131        };
132
133        if self.processes.contains_key(&name) {
134            return Response::Error {
135                code: ErrorCode::General,
136                message: format!("process already exists: {}", name),
137            };
138        }
139
140        let log_dir = paths::log_dir(&self.session);
141        let _ = std::fs::create_dir_all(&log_dir);
142
143        let mut cmd = Command::new("sh");
144        cmd.arg("-c")
145            .arg(command)
146            .stdout(Stdio::piped())
147            .stderr(Stdio::piped());
148        if let Some(dir) = cwd {
149            cmd.current_dir(dir);
150        }
151        if let Some(p) = resolved_port {
152            // Inject PORT and HOST; user-supplied env takes precedence
153            let mut merged_env: HashMap<String, String> = HashMap::new();
154            merged_env.insert("PORT".to_string(), p.to_string());
155            merged_env.insert("HOST".to_string(), "127.0.0.1".to_string());
156            if let Some(env_vars) = env {
157                for (k, v) in env_vars {
158                    merged_env.insert(k.clone(), v.clone());
159                }
160            }
161            cmd.envs(&merged_env);
162        } else if let Some(env_vars) = env {
163            cmd.envs(env_vars);
164        }
165        // SAFETY: `setpgid(0, 0)` creates a new process group with the child as
166        // leader.  This must happen before exec so that all grandchildren inherit
167        // the group.  The parent uses this PGID to signal the entire tree on stop.
168        unsafe {
169            cmd.pre_exec(|| {
170                nix::unistd::setpgid(nix::unistd::Pid::from_raw(0), nix::unistd::Pid::from_raw(0))
171                    .map_err(std::io::Error::other)?;
172                Ok(())
173            });
174        }
175
176        let mut child = match cmd.spawn() {
177            Ok(c) => c,
178            Err(e) => {
179                return Response::Error {
180                    code: ErrorCode::General,
181                    message: format!("failed to spawn: {}", e),
182                };
183            }
184        };
185
186        let pid = child.id().unwrap_or(0);
187
188        // Shared sequence counter for interleaved ordering across streams
189        let seq_counter = Arc::new(AtomicU64::new(0));
190
191        // Spawn output capture tasks via log_writer
192        // Supervisor channel: stdout gets the real sender, stderr gets a dummy
193        let (sup_tx, sup_rx_stdout) = tokio::sync::mpsc::channel::<String>(16);
194        let (stderr_sup_sender, sup_rx_stderr) = tokio::sync::mpsc::channel::<String>(16);
195        drop(stderr_sup_sender);
196
197        let mut capture_handles = Vec::new();
198
199        if let Some(stdout) = child.stdout.take() {
200            let tx = self.output_tx.clone();
201            let pname = name.clone();
202            let path = log_dir.join(format!("{}.stdout", name));
203            let seq = Arc::clone(&seq_counter);
204            let handle = tokio::spawn(async move {
205                log_writer::capture_output(
206                    stdout,
207                    &path,
208                    &pname,
209                    ProtoStream::Stdout,
210                    tx,
211                    DEFAULT_MAX_LOG_BYTES,
212                    log_writer::DEFAULT_MAX_ROTATED_FILES,
213                    seq,
214                    sup_rx_stdout,
215                )
216                .await;
217            });
218            capture_handles.push(handle);
219        }
220        if let Some(stderr) = child.stderr.take() {
221            let tx = self.output_tx.clone();
222            let pname = name.clone();
223            let path = log_dir.join(format!("{}.stderr", name));
224            let seq = Arc::clone(&seq_counter);
225            let handle = tokio::spawn(async move {
226                log_writer::capture_output(
227                    stderr,
228                    &path,
229                    &pname,
230                    ProtoStream::Stderr,
231                    tx,
232                    DEFAULT_MAX_LOG_BYTES,
233                    log_writer::DEFAULT_MAX_ROTATED_FILES,
234                    seq,
235                    sup_rx_stderr,
236                )
237                .await;
238            });
239            capture_handles.push(handle);
240        }
241
242        self.processes.insert(
243            name.clone(),
244            ManagedProcess {
245                name: name.clone(),
246                id: id.clone(),
247                command: command.to_string(),
248                cwd: cwd.map(std::string::ToString::to_string),
249                env: env.cloned().unwrap_or_default(),
250                child: Some(child),
251                pid,
252                started_at: Instant::now(),
253                exit_code: None,
254                port: resolved_port,
255                restart_policy: None,
256                watch_config: None,
257                restart_count: 0,
258                manually_stopped: false,
259                restart_pending: false,
260                failed: false,
261                supervisor_tx: Some(sup_tx),
262                capture_handles,
263                watch_handle: None,
264            },
265        );
266
267        let url = resolved_port.map(|p| process_url(&name, p, None));
268        Response::RunOk {
269            name,
270            id,
271            pid,
272            port: resolved_port,
273            url,
274        }
275    }
276
277    pub async fn stop_process(&mut self, target: &str) -> Response {
278        let proc = match self.find_mut(target) {
279            Some(p) => p,
280            None => {
281                return Response::Error {
282                    code: ErrorCode::NotFound,
283                    message: format!("process not found: {}", target),
284                };
285            }
286        };
287
288        proc.manually_stopped = true;
289
290        if let Some(ref child) = proc.child {
291            let raw_pid = child.id().unwrap_or(0) as i32;
292            if raw_pid > 0 {
293                // Signal the entire process group (child PID == PGID due to setpgid in pre_exec)
294                let pgid = nix::unistd::Pid::from_raw(raw_pid);
295                let _ = nix::sys::signal::killpg(pgid, nix::sys::signal::Signal::SIGTERM);
296            }
297        }
298
299        // Wait up to 10s for graceful exit, then SIGKILL
300        if let Some(ref mut child) = proc.child {
301            let wait_result = tokio::time::timeout(Duration::from_secs(10), child.wait()).await;
302
303            match wait_result {
304                Ok(Ok(status)) => {
305                    proc.exit_code = status.code();
306                }
307                _ => {
308                    // Timed out or error — force kill the process group
309                    let raw_pid = proc.pid as i32;
310                    if raw_pid > 0 {
311                        let pgid = nix::unistd::Pid::from_raw(raw_pid);
312                        let _ = nix::sys::signal::killpg(pgid, nix::sys::signal::Signal::SIGKILL);
313                    }
314                    let _ = child.wait().await;
315                    proc.exit_code = Some(-9);
316                }
317            }
318            proc.child = None;
319        }
320
321        Response::Ok {
322            message: format!("stopped {}", target),
323        }
324    }
325
326    pub async fn stop_all(&mut self) -> Response {
327        let names: Vec<String> = self.processes.keys().cloned().collect();
328        for name in names {
329            let _ = self.stop_process(&name).await;
330        }
331        self.processes.clear();
332        Response::Ok {
333            message: "all processes stopped".into(),
334        }
335    }
336
337    pub async fn restart_process(&mut self, target: &str) -> Response {
338        let (command, name, cwd, env, port, restart_policy, watch_config) = match self.find(target)
339        {
340            Some(p) => (
341                p.command.clone(),
342                p.name.clone(),
343                p.cwd.clone(),
344                p.env.clone(),
345                p.port,
346                p.restart_policy.clone(),
347                p.watch_config.clone(),
348            ),
349            None => {
350                return Response::Error {
351                    code: ErrorCode::NotFound,
352                    message: format!("process not found: {}", target),
353                };
354            }
355        };
356        // Clear supervisor flags — restart is intentional
357        if let Some(p) = self.find_mut(target) {
358            p.manually_stopped = false;
359            p.restart_count = 0;
360            p.failed = false;
361            p.restart_pending = false;
362        }
363        let _ = self.stop_process(target).await;
364        self.processes.remove(&name);
365        let env = if env.is_empty() { None } else { Some(env) };
366        let resp = self
367            .spawn_process(
368                &command,
369                Some(name.clone()),
370                cwd.as_deref(),
371                env.as_ref(),
372                port,
373            )
374            .await;
375        // Re-attach restart/watch config so manual restart preserves supervisor behavior
376        if let Response::RunOk { .. } = resp
377            && let Some(p) = self.find_mut(&name)
378        {
379            p.restart_policy = restart_policy;
380            p.watch_config = watch_config;
381        }
382        resp
383    }
384
385    pub fn enable_proxy(&mut self) {
386        self.port_allocator.enable_proxy();
387    }
388
389    pub fn status(&mut self) -> Response {
390        self.refresh_exit_states();
391        Response::Status {
392            processes: self.build_process_infos(),
393        }
394    }
395
396    /// Returns `None` if process not found or still running.
397    /// Returns `Some(exit_code)` if process has exited (`exit_code` is None for signal kills).
398    pub fn is_process_exited(&mut self, target: &str) -> Option<Option<i32>> {
399        self.refresh_exit_states();
400        self.find(target).and_then(|p| {
401            if p.child.is_none() {
402                Some(p.exit_code)
403            } else {
404                None
405            }
406        })
407    }
408
409    pub(crate) fn refresh_exit_states(&mut self) -> bool {
410        let mut changed = false;
411        for proc in self.processes.values_mut() {
412            if proc.child.is_some()
413                && proc.exit_code.is_none()
414                && let Some(ref mut child) = proc.child
415                && let Ok(Some(status)) = child.try_wait()
416            {
417                proc.exit_code = status.code();
418                proc.child = None;
419                changed = true;
420            }
421        }
422        changed
423    }
424
425    pub fn session_name(&self) -> &str {
426        &self.session
427    }
428
429    pub fn has_process(&self, target: &str) -> bool {
430        self.find(target).is_some()
431    }
432
433    /// Returns a map of running process names to their assigned ports.
434    pub fn running_ports(&self) -> HashMap<String, u16> {
435        self.processes
436            .iter()
437            .filter_map(|(name, p)| {
438                if p.child.is_some() {
439                    p.port.map(|port| (name.clone(), port))
440                } else {
441                    None
442                }
443            })
444            .collect()
445    }
446
447    /// Non-mutating status snapshot for use by the proxy status page.
448    /// May show stale exit states since it skips `refresh_exit_states()`.
449    pub fn status_snapshot(&self) -> Response {
450        Response::Status {
451            processes: self.build_process_infos(),
452        }
453    }
454
455    fn build_process_infos(&self) -> Vec<ProcessInfo> {
456        let mut infos: Vec<ProcessInfo> = self
457            .processes
458            .values()
459            .map(|p| ProcessInfo {
460                name: p.name.clone(),
461                id: p.id.clone(),
462                pid: p.pid,
463                state: if p.child.is_some() {
464                    ProcessState::Running
465                } else if p.failed {
466                    ProcessState::Failed
467                } else {
468                    ProcessState::Exited
469                },
470                exit_code: p.exit_code,
471                uptime_secs: if p.child.is_some() {
472                    Some(p.started_at.elapsed().as_secs())
473                } else {
474                    None
475                },
476                command: p.command.clone(),
477                port: p.port,
478                url: p.port.map(|port| process_url(&p.name, port, None)),
479                restart_count: if p.restart_count > 0 {
480                    Some(p.restart_count)
481                } else {
482                    None
483                },
484                max_restarts: p.restart_policy.as_ref().and_then(|rp| rp.max_restarts),
485                restart_policy: p.restart_policy.as_ref().map(|rp| match rp.mode {
486                    RestartMode::Always => "always".into(),
487                    RestartMode::OnFailure => "on-failure".into(),
488                    RestartMode::Never => "never".into(),
489                }),
490                watched: if p.watch_config.is_some() {
491                    Some(true)
492                } else {
493                    None
494                },
495            })
496            .collect();
497        infos.sort_by(|a, b| a.name.cmp(&b.name));
498        infos
499    }
500
501    pub(crate) fn find(&self, target: &str) -> Option<&ManagedProcess> {
502        self.processes
503            .get(target)
504            .or_else(|| self.processes.values().find(|p| p.id == target))
505    }
506
507    pub(crate) fn find_mut(&mut self, target: &str) -> Option<&mut ManagedProcess> {
508        if self.processes.contains_key(target) {
509            self.processes.get_mut(target)
510        } else {
511            self.processes.values_mut().find(|p| p.id == target)
512        }
513    }
514
515    /// Classify exited processes that have a restart policy into "restartable" vs "exhausted".
516    /// Returns `(restartable, exhausted)` name vectors in a single pass.
517    pub fn classify_restart_candidates(&self) -> (Vec<String>, Vec<String>) {
518        let mut restartable = Vec::new();
519        let mut exhausted = Vec::new();
520        for p in self.processes.values() {
521            if p.child.is_some() || p.manually_stopped || p.restart_pending || p.failed {
522                continue;
523            }
524            let Some(ref policy) = p.restart_policy else {
525                continue;
526            };
527            if !policy.mode.should_restart(p.exit_code) {
528                continue;
529            }
530            if policy
531                .max_restarts
532                .is_some_and(|max| p.restart_count >= max)
533            {
534                exhausted.push(p.name.clone());
535            } else {
536                restartable.push(p.name.clone());
537            }
538        }
539        (restartable, exhausted)
540    }
541
542    /// Mark a process as failed (max restarts exhausted).
543    pub fn mark_failed(&mut self, target: &str) {
544        if let Some(p) = self.find_mut(target) {
545            p.failed = true;
546        }
547    }
548
549    /// Re-spawn a process in place, preserving supervisor metadata.
550    /// Drains capture tasks, rotates logs, re-spawns, and carries over metadata.
551    /// On spawn failure, reinserts a tombstone record with failed=true.
552    pub async fn respawn_in_place(&mut self, target: &str) -> Result<(), String> {
553        let proc = self
554            .find(target)
555            .ok_or_else(|| format!("process not found: {}", target))?;
556
557        // 1. Save metadata and spawn args
558        let command = proc.command.clone();
559        let name = proc.name.clone();
560        let cwd = proc.cwd.clone();
561        let env = proc.env.clone();
562        let port = proc.port;
563        let restart_policy = proc.restart_policy.clone();
564        let watch_config = proc.watch_config.clone();
565        let restart_count = proc.restart_count;
566        let restart_pending = proc.restart_pending;
567
568        // 2. Drop supervisor sender (signals capture task to drain)
569        if let Some(proc) = self.find_mut(target) {
570            proc.supervisor_tx = None;
571        }
572
573        // 3. Await capture task JoinHandles
574        if let Some(proc) = self.find_mut(target) {
575            let handles = std::mem::take(&mut proc.capture_handles);
576            for h in handles {
577                let _ = h.await;
578            }
579        }
580
581        // 4. Rotate log files
582        let log_dir = crate::paths::log_dir(&self.session);
583        let stdout_path = log_dir.join(format!("{}.stdout", name));
584        let stderr_path = log_dir.join(format!("{}.stderr", name));
585        log_writer::rotate_if_exists(&stdout_path).await;
586        log_writer::rotate_if_exists(&stderr_path).await;
587
588        // 5. Remove old record
589        self.processes.remove(&name);
590
591        // 6. Spawn fresh
592        let env_opt = if env.is_empty() {
593            None
594        } else {
595            Some(env.clone())
596        };
597        let resp = self
598            .spawn_process(
599                &command,
600                Some(name.clone()),
601                cwd.as_deref(),
602                env_opt.as_ref(),
603                port,
604            )
605            .await;
606
607        match resp {
608            Response::RunOk { .. } => {
609                // 7. Copy metadata to new record
610                if let Some(p) = self.find_mut(&name) {
611                    p.restart_policy = restart_policy;
612                    p.watch_config = watch_config;
613                    p.restart_count = restart_count;
614                    p.failed = false;
615                }
616                Ok(())
617            }
618            Response::Error { message, .. } => {
619                // 8. Reinsert tombstone
620                self.processes.insert(
621                    name.clone(),
622                    ManagedProcess {
623                        name: name.clone(),
624                        id: "tombstone".into(),
625                        command,
626                        cwd,
627                        env,
628                        child: None,
629                        pid: 0,
630                        started_at: Instant::now(),
631                        exit_code: None,
632                        port,
633                        restart_policy,
634                        watch_config,
635                        restart_count,
636                        manually_stopped: false,
637                        restart_pending,
638                        failed: true,
639                        supervisor_tx: None,
640                        capture_handles: Vec::new(),
641                        watch_handle: None,
642                    },
643                );
644                Err(message)
645            }
646            _ => Err("unexpected response from spawn".into()),
647        }
648    }
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654
655    #[test]
656    fn test_valid_dns_labels() {
657        assert!(is_valid_dns_label("api"));
658        assert!(is_valid_dns_label("my-app"));
659        assert!(is_valid_dns_label("a"));
660        assert!(is_valid_dns_label("a1"));
661        assert!(is_valid_dns_label("123"));
662    }
663
664    #[test]
665    fn test_invalid_dns_labels() {
666        assert!(!is_valid_dns_label(""));
667        assert!(!is_valid_dns_label("-start"));
668        assert!(!is_valid_dns_label("end-"));
669        assert!(!is_valid_dns_label("UPPER"));
670        assert!(!is_valid_dns_label("has.dot"));
671        assert!(!is_valid_dns_label("has space"));
672        assert!(!is_valid_dns_label(&"a".repeat(64))); // > 63 chars
673        assert!(!is_valid_dns_label("has_underscore"));
674    }
675
676    #[tokio::test]
677    async fn test_respawn_in_place_preserves_metadata() {
678        let mut pm = ProcessManager::new("test-respawn");
679        let resp = pm
680            .spawn_process("echo hello", Some("worker".into()), None, None, None)
681            .await;
682        assert!(matches!(resp, Response::RunOk { .. }));
683
684        // Set supervisor metadata
685        if let Some(p) = pm.find_mut("worker") {
686            p.restart_policy = Some(RestartPolicy {
687                mode: RestartMode::OnFailure,
688                max_restarts: Some(5),
689                restart_delay_ms: 1000,
690            });
691            p.restart_count = 3;
692        }
693
694        // Wait for the process to exit
695        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
696        pm.refresh_exit_states();
697
698        // Respawn
699        let result = pm.respawn_in_place("worker").await;
700        assert!(result.is_ok());
701
702        // Verify metadata preserved
703        let p = pm.find("worker").unwrap();
704        assert!(p.child.is_some()); // new process running
705        assert_eq!(p.restart_count, 3);
706        assert!(p.restart_policy.is_some());
707        assert_eq!(
708            p.restart_policy.as_ref().unwrap().mode,
709            RestartMode::OnFailure
710        );
711        assert!(!p.failed);
712    }
713
714    #[tokio::test]
715    async fn test_respawn_in_place_tombstone_on_failure() {
716        let mut pm = ProcessManager::new("test-tombstone");
717        let resp = pm
718            .spawn_process("echo hello", Some("worker".into()), None, None, None)
719            .await;
720        assert!(matches!(resp, Response::RunOk { .. }));
721
722        if let Some(p) = pm.find_mut("worker") {
723            p.restart_policy = Some(RestartPolicy {
724                mode: RestartMode::Always,
725                max_restarts: Some(3),
726                restart_delay_ms: 1000,
727            });
728            p.restart_count = 2;
729            // Corrupt name to contain path traversal — triggers spawn validation error
730            p.name = "work/er".to_string();
731        }
732
733        // Re-key in the processes map under the corrupted name
734        let proc = pm.processes.remove("worker").unwrap();
735        pm.processes.insert("work/er".to_string(), proc);
736
737        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
738        pm.refresh_exit_states();
739
740        let result = pm.respawn_in_place("work/er").await;
741        assert!(result.is_err());
742
743        // Tombstone should exist
744        let p = pm.find("work/er").unwrap();
745        assert!(p.child.is_none());
746        assert!(p.failed);
747        assert_eq!(p.restart_count, 2); // preserved
748        assert!(p.restart_policy.is_some());
749    }
750
751    /// Helper: build an exited `ManagedProcess` with sensible defaults.
752    fn make_exited_process(name: &str, exit_code: Option<i32>) -> ManagedProcess {
753        ManagedProcess {
754            name: name.to_string(),
755            id: format!("id-{}", name),
756            command: "true".to_string(),
757            cwd: None,
758            env: HashMap::new(),
759            child: None,
760            pid: 0,
761            started_at: Instant::now(),
762            exit_code,
763            port: None,
764            restart_policy: None,
765            watch_config: None,
766            restart_count: 0,
767            manually_stopped: false,
768            restart_pending: false,
769            failed: false,
770            supervisor_tx: None,
771            capture_handles: Vec::new(),
772            watch_handle: None,
773        }
774    }
775
776    #[tokio::test]
777    async fn test_classify_skips_running_process() {
778        let mut pm = ProcessManager::new("test-classify-running");
779        let mut proc = make_exited_process("runner", Some(1));
780        proc.restart_policy = Some(RestartPolicy {
781            mode: RestartMode::Always,
782            max_restarts: None,
783            restart_delay_ms: 0,
784        });
785        // Simulate a running process by spawning a real tokio child
786        let child = Command::new("sleep")
787            .arg("60")
788            .stdout(std::process::Stdio::null())
789            .stderr(std::process::Stdio::null())
790            .spawn()
791            .unwrap();
792        let pid = child.id().unwrap_or(0) as i32;
793        proc.child = Some(child);
794        pm.processes.insert("runner".into(), proc);
795
796        let (restartable, exhausted) = pm.classify_restart_candidates();
797        assert!(restartable.is_empty());
798        assert!(exhausted.is_empty());
799
800        // Clean up the child process
801        if pid > 0 {
802            let _ = nix::sys::signal::kill(
803                nix::unistd::Pid::from_raw(pid),
804                nix::sys::signal::Signal::SIGKILL,
805            );
806        }
807        if let Some(p) = pm.processes.get_mut("runner")
808            && let Some(ref mut c) = p.child
809        {
810            let _ = c.wait().await;
811        }
812    }
813
814    #[test]
815    fn test_classify_skips_manually_stopped() {
816        let mut pm = ProcessManager::new("test-classify-stopped");
817        let mut proc = make_exited_process("stopped", Some(1));
818        proc.restart_policy = Some(RestartPolicy {
819            mode: RestartMode::Always,
820            max_restarts: None,
821            restart_delay_ms: 0,
822        });
823        proc.manually_stopped = true;
824        pm.processes.insert("stopped".into(), proc);
825
826        let (restartable, exhausted) = pm.classify_restart_candidates();
827        assert!(restartable.is_empty());
828        assert!(exhausted.is_empty());
829    }
830
831    #[test]
832    fn test_classify_skips_clean_exit_on_failure_mode() {
833        let mut pm = ProcessManager::new("test-classify-clean");
834        let mut proc = make_exited_process("clean", Some(0));
835        proc.restart_policy = Some(RestartPolicy {
836            mode: RestartMode::OnFailure,
837            max_restarts: None,
838            restart_delay_ms: 0,
839        });
840        pm.processes.insert("clean".into(), proc);
841
842        let (restartable, exhausted) = pm.classify_restart_candidates();
843        assert!(restartable.is_empty());
844        assert!(exhausted.is_empty());
845    }
846
847    #[test]
848    fn test_classify_skips_restart_pending() {
849        let mut pm = ProcessManager::new("test-classify-pending");
850        let mut proc = make_exited_process("pending", Some(1));
851        proc.restart_policy = Some(RestartPolicy {
852            mode: RestartMode::Always,
853            max_restarts: None,
854            restart_delay_ms: 0,
855        });
856        proc.restart_pending = true;
857        pm.processes.insert("pending".into(), proc);
858
859        let (restartable, exhausted) = pm.classify_restart_candidates();
860        assert!(restartable.is_empty());
861        assert!(exhausted.is_empty());
862    }
863
864    #[test]
865    fn test_classify_restartable_on_failure() {
866        let mut pm = ProcessManager::new("test-classify-on-failure");
867        let mut proc = make_exited_process("crasher", Some(1));
868        proc.restart_policy = Some(RestartPolicy {
869            mode: RestartMode::OnFailure,
870            max_restarts: None,
871            restart_delay_ms: 0,
872        });
873        pm.processes.insert("crasher".into(), proc);
874
875        let (restartable, exhausted) = pm.classify_restart_candidates();
876        assert_eq!(restartable, vec!["crasher"]);
877        assert!(exhausted.is_empty());
878    }
879
880    #[test]
881    fn test_classify_exhausted() {
882        let mut pm = ProcessManager::new("test-classify-exhausted");
883        let mut proc = make_exited_process("tired", Some(1));
884        proc.restart_policy = Some(RestartPolicy {
885            mode: RestartMode::Always,
886            max_restarts: Some(3),
887            restart_delay_ms: 0,
888        });
889        proc.restart_count = 3;
890        pm.processes.insert("tired".into(), proc);
891
892        let (restartable, exhausted) = pm.classify_restart_candidates();
893        assert!(restartable.is_empty());
894        assert_eq!(exhausted, vec!["tired"]);
895    }
896
897    #[test]
898    fn test_classify_always_mode_clean_exit() {
899        let mut pm = ProcessManager::new("test-classify-always-clean");
900        let mut proc = make_exited_process("always-on", Some(0));
901        proc.restart_policy = Some(RestartPolicy {
902            mode: RestartMode::Always,
903            max_restarts: None,
904            restart_delay_ms: 0,
905        });
906        pm.processes.insert("always-on".into(), proc);
907
908        let (restartable, exhausted) = pm.classify_restart_candidates();
909        assert_eq!(restartable, vec!["always-on"]);
910        assert!(exhausted.is_empty());
911    }
912}