Skip to main content

systemprompt_agent/services/agent_orchestration/process/
mod.rs

1//! Process spawning and lifecycle helpers used by the agent orchestrator.
2//!
3//! - `command` builds the `Command` for an agent subprocess and rotates its log
4//!   file.
5//! - `signals` cross-platform `process_exists`, `terminate_process`,
6//!   `force_kill_process`, `terminate_gracefully`, `kill_process`, and their
7//!   identity-gated forms `terminate_gracefully_verified` /
8//!   `kill_process_verified` that refuse to signal a recycled PID.
9
10mod command;
11mod signals;
12
13use std::fs;
14use systemprompt_config::{ProfileBootstrap, SecretsBootstrap};
15use systemprompt_models::paths::BuildPaths;
16use systemprompt_models::{AppPaths, Config};
17
18use crate::services::agent_orchestration::{OrchestrationError, OrchestrationResult};
19
20pub use signals::{
21    force_kill_process, kill_process, kill_process_verified, process_exists, terminate_gracefully,
22    terminate_gracefully_verified, terminate_process,
23};
24
25pub fn spawn_detached(paths: &AppPaths, agent_name: &str, port: u16) -> OrchestrationResult<u32> {
26    let binary_path = BuildPaths::resolve_self().map_err(|e| {
27        OrchestrationError::ProcessSpawnFailed(format!("Failed to resolve running binary: {e}"))
28    })?;
29
30    let config = Config::get().map_err(|e| {
31        OrchestrationError::ProcessSpawnFailed(format!("Failed to get config: {e}"))
32    })?;
33
34    let secrets = SecretsBootstrap::get().map_err(|e| {
35        OrchestrationError::ProcessSpawnFailed(format!("Failed to get secrets: {e}"))
36    })?;
37
38    let profile_path = ProfileBootstrap::get_path().map_err(|e| {
39        OrchestrationError::ProcessSpawnFailed(format!("Failed to get profile path: {e}"))
40    })?;
41
42    let log_file = command::prepare_agent_log_file(agent_name, &paths.system().logs())?;
43
44    let mut cmd = command::build_agent_command(command::BuildAgentCommandParams {
45        binary_path: &binary_path,
46        agent_name,
47        port,
48        profile_path,
49        secrets,
50        config,
51        log_file,
52    });
53
54    let child = cmd.spawn().map_err(|e| {
55        OrchestrationError::ProcessSpawnFailed(format!("Failed to spawn {agent_name}: {e}"))
56    })?;
57
58    let pid = child.id();
59    #[expect(
60        clippy::mem_forget,
61        reason = "detached agent process: skip Child's drop-time wait so the spawned agent keeps \
62                  running after this fn returns"
63    )]
64    std::mem::forget(child);
65
66    if !signals::verify_process_started(pid) {
67        return Err(OrchestrationError::ProcessSpawnFailed(format!(
68            "Agent {} (PID {}) died immediately after spawn",
69            agent_name, pid
70        )));
71    }
72
73    tracing::debug!(pid = %pid, agent_name = %agent_name, "Detached process spawned");
74    Ok(pid)
75}
76
77pub fn is_port_in_use(port: u16) -> bool {
78    use std::net::TcpListener;
79    TcpListener::bind(format!("127.0.0.1:{port}")).is_err()
80}
81
82pub fn validate_agent_binary() -> crate::error::AgentResult<()> {
83    let binary_path = BuildPaths::resolve_self()
84        .map_err(|e| crate::error::AgentError::Validation(e.to_string()))?;
85
86    let metadata = fs::metadata(&binary_path).map_err(crate::error::AgentError::Io)?;
87
88    if !metadata.is_file() {
89        return Err(crate::error::AgentError::Validation(format!(
90            "Agent binary is not a file: {}",
91            binary_path.display()
92        )));
93    }
94
95    #[cfg(unix)]
96    {
97        use std::os::unix::fs::PermissionsExt;
98        let permissions = metadata.permissions();
99        if permissions.mode() & 0o111 == 0 {
100            return Err(crate::error::AgentError::Validation(format!(
101                "Agent binary is not executable: {}",
102                binary_path.display()
103            )));
104        }
105    }
106
107    Ok(())
108}