agent-manager v0.2.0 : gestionnaire d'agents IA locaux
- CLI Rust (am) : list/install/uninstall/update/start/stop/restart/run/status/search/info/config/doctor/completion/export/import/self-update + shell interactif (Tab, historique) - 9 methodes d'installation (npm, pip, uv, cargo, go, bun, curl, binary, git) - catalogue de 72 agents, dependances, processus, securite (dry-run, checksums) - sondes de version paralleles + cache (am list ~25 ms a chaud) - installateurs one-liner : install.sh, install.ps1
This commit is contained in:
+221
@@ -0,0 +1,221 @@
|
||||
//! Process management: detached background start, PID bookkeeping, stop
|
||||
//! with a graceful signal followed by a forceful kill after a timeout.
|
||||
|
||||
use crate::app::App;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Log file for a background agent.
|
||||
pub fn agent_log_path(app: &App, agent_name: &str) -> PathBuf {
|
||||
app.log.log_dir().join(format!("{agent_name}.log"))
|
||||
}
|
||||
|
||||
/// Is a process with this PID alive?
|
||||
pub fn is_running(pid: u32) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let out = Command::new("tasklist")
|
||||
.args(["/FI", &format!("PID eq {pid}"), "/NH"])
|
||||
.output();
|
||||
match out {
|
||||
Ok(o) => String::from_utf8_lossy(&o.stdout)
|
||||
.split_whitespace()
|
||||
.any(|w| w == pid.to_string()),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
Command::new("kill")
|
||||
.args(["-0", &pid.to_string()])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Send SIGTERM/SIGKILL (or taskkill / taskkill /F on Windows).
|
||||
fn signal(pid: u32, force: bool) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let mut cmd = Command::new("taskkill");
|
||||
cmd.arg("/PID").arg(pid.to_string());
|
||||
if force {
|
||||
cmd.arg("/T").arg("/F");
|
||||
}
|
||||
cmd.status().map(|s| s.success()).unwrap_or(false)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let sig = if force { "-KILL" } else { "-TERM" };
|
||||
Command::new("kill")
|
||||
.arg(sig)
|
||||
.arg(pid.to_string())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop a process: TERM first, then KILL after the timeout. Returns whether
|
||||
/// the process was running when the call started.
|
||||
pub fn stop_pid(pid: u32, force: bool, timeout_secs: u64) -> bool {
|
||||
if !is_running(pid) {
|
||||
return false;
|
||||
}
|
||||
if force {
|
||||
signal(pid, true);
|
||||
} else {
|
||||
signal(pid, false);
|
||||
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
|
||||
while Instant::now() < deadline {
|
||||
if !is_running(pid) {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
if is_running(pid) {
|
||||
signal(pid, true);
|
||||
}
|
||||
}
|
||||
for _ in 0..50 {
|
||||
if !is_running(pid) {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
!is_running(pid)
|
||||
}
|
||||
|
||||
/// Start a program detached: stdout/stderr are redirected to the agent log
|
||||
/// file. Returns the PID. Fails when the process exits immediately.
|
||||
pub fn spawn_background(
|
||||
app: &App,
|
||||
agent_name: &str,
|
||||
program: &Path,
|
||||
args: &[String],
|
||||
env: &BTreeMap<String, String>,
|
||||
) -> Result<u32> {
|
||||
let log = agent_log_path(app, agent_name);
|
||||
if app.dry_run() {
|
||||
app.log.dry(format!(
|
||||
"would start {} (detached, log: {})",
|
||||
program.display(),
|
||||
log.display()
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
if let Some(parent) = log.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let file = std::fs::File::create(&log)
|
||||
.with_context(|| format!("cannot create log file {}", log.display()))?;
|
||||
let (prog, prefix) = crate::runner::resolve_program(&program.display().to_string());
|
||||
let mut full_args = prefix;
|
||||
full_args.extend(args.iter().cloned());
|
||||
let mut cmd = Command::new(&prog);
|
||||
cmd.args(&full_args)
|
||||
.envs(env)
|
||||
.stdout(Stdio::from(file.try_clone()?))
|
||||
.stderr(Stdio::from(file));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
cmd.process_group(0);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
|
||||
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
|
||||
}
|
||||
app.log
|
||||
.cmd(&format!("{} {}", program.display(), args.join(" ")));
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to start {}", program.display()))?;
|
||||
let pid = child.id();
|
||||
// Give the process a moment: some agents crash at startup.
|
||||
std::thread::sleep(Duration::from_millis(400));
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
let tail = last_lines(&log, 8);
|
||||
bail!(
|
||||
"agent '{agent_name}' exited immediately after start (status {status}); log: {}
|
||||
{}",
|
||||
log.display(),
|
||||
tail
|
||||
);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(_) => {}
|
||||
}
|
||||
Ok(pid)
|
||||
}
|
||||
|
||||
/// Tail of a text file (last n lines).
|
||||
pub fn last_lines(path: &Path, n: usize) -> String {
|
||||
let Ok(text) = std::fs::read_to_string(path) else {
|
||||
return String::new();
|
||||
};
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let start = lines.len().saturating_sub(n);
|
||||
lines[start..].join("
|
||||
")
|
||||
}
|
||||
|
||||
/// Best-effort desktop notification once an agent has started.
|
||||
pub fn desktop_notify(title: &str, body: &str) {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let _ = Command::new("notify-send")
|
||||
.args(["-a", "agent-manager", title, body])
|
||||
.spawn();
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = Command::new("osascript")
|
||||
.args([
|
||||
"-e",
|
||||
&format!("display notification \"{body}\" with title \"{title}\""),
|
||||
])
|
||||
.spawn();
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// No built-in non-blocking toast without extra tooling; log instead.
|
||||
let _ = (title, body);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sleep_cmd() -> (&'static str, Vec<String>) {
|
||||
if cfg!(windows) {
|
||||
("ping", vec!["-n".to_string(), "30".to_string(), "127.0.0.1".to_string()])
|
||||
} else {
|
||||
("sleep", vec!["30".to_string()])
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pid_lifecycle() {
|
||||
let (prog, args) = sleep_cmd();
|
||||
let mut child = Command::new(prog).args(&args).spawn().unwrap();
|
||||
let pid = child.id();
|
||||
assert!(is_running(pid));
|
||||
assert!(stop_pid(pid, true, 5));
|
||||
assert!(!is_running(pid));
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_non_running_is_false() {
|
||||
assert!(!stop_pid(u32::MAX - 1, true, 1));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user