From 8d93eece1e596885a9ed05189c5b60653551f9d4 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 17 Aug 2026 08:58:19 -0400 Subject: [PATCH] watch : supervision d'un agent avec redemarrage automatique et backoff (issue #35) - check_once testable, notification desktop, Ctrl-C n'affecte pas l'agent detache --- src/cli.rs | 14 +++++ src/commands/mod.rs | 7 +++ src/commands/watch_cmd.rs | 107 ++++++++++++++++++++++++++++++++++++++ src/help.rs | 20 +++++++ src/repl.rs | 10 +++- 5 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 src/commands/watch_cmd.rs diff --git a/src/cli.rs b/src/cli.rs index afa6546..24ef07a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -91,6 +91,20 @@ pub enum Command { /// Agent name or alias agent: String, }, + /// Supervise an agent and optionally restart it on crash + Watch { + /// Agent name or alias + agent: String, + /// Restart the agent automatically when it dies (with backoff) + #[arg(long)] + restart: bool, + /// Desktop notification on every restart + #[arg(long)] + notify: bool, + /// Check interval in seconds (default: 5) + #[arg(long, value_name = "SECS", default_value = "5")] + interval: u64, + }, /// Manage command aliases (cc -> claude-code) #[command(subcommand)] Alias(AliasCmd), diff --git a/src/commands/mod.rs b/src/commands/mod.rs index fa91217..9b55cd5 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -25,6 +25,7 @@ pub mod status_cmd; pub mod timeline_cmd; pub mod uninstall_cmd; pub mod update_cmd; +pub mod watch_cmd; pub mod version_cmd; use crate::app::App; @@ -57,6 +58,12 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { follow, } => logs_cmd::run(app, agent, *lines, *follow), Command::Open { agent } => open_cmd::run(app, agent), + Command::Watch { + agent, + restart, + notify, + interval, + } => watch_cmd::run(app, agent, *restart, *notify, *interval), Command::Alias(sub) => alias_cmd::run(app, sub), Command::Start(a) => run_cmd::start(app, a), Command::Stop { diff --git a/src/commands/watch_cmd.rs b/src/commands/watch_cmd.rs new file mode 100644 index 0000000..a27435d --- /dev/null +++ b/src/commands/watch_cmd.rs @@ -0,0 +1,107 @@ +//! watch: supervise one agent and optionally restart it on crash. + +use super::*; +use crate::cli::StartArgs; +use crate::process; +use std::time::Duration; + +/// One supervision check. Returns whether the agent is running afterwards. +/// Restarts are background starts (event + session recorded as usual). +pub fn check_once(app: &App, agent_name: &str, restart: bool, notify: bool) -> bool { + let entry = app.state.get(agent_name).ok().flatten(); + let running = entry + .as_ref() + .and_then(|e| e.pid) + .map(process::is_running) + .unwrap_or(false); + if running { + return true; + } + if !restart { + app.log.warn(&format!("'{agent_name}' is not running")); + return false; + } + let agent = match app.catalog.resolve(agent_name) { + Some(a) => a, + None => { + app.log.error(&format!("unknown agent '{agent_name}'")); + return false; + } + }; + app.log.warn(&format!("'{agent_name}' is not running — restarting")); + let opts = StartArgs { + agent: Some(agent_name.to_string()), + background: true, + ..Default::default() + }; + match run_cmd::start(app, &opts) { + Ok(_) => { + if notify { + process::desktop_notify("agent-manager", &format!("{} restarted", agent.title())); + } + true + } + Err(e) => { + app.log.error(&format!("restart failed: {e:#}")); + false + } + } +} + +pub fn run(app: &App, agent: &str, restart: bool, notify: bool, interval: u64) -> Result { + require_agent(app, agent)?; + let interval = interval.max(1); + app.log.info(&format!("watching '{agent}' every {interval}s (Ctrl-C to stop)")); + let mut backoff = 1u64; + loop { + let ok = check_once(app, agent, restart, notify); + if ok { + backoff = 1; + std::thread::sleep(Duration::from_secs(interval)); + } else if restart { + app.log.warn(&format!("retrying in {backoff}s")); + std::thread::sleep(Duration::from_secs(backoff)); + backoff = (backoff * 2).min(60); + } else { + std::thread::sleep(Duration::from_secs(interval)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[test] + fn check_once_reports_dead_agent_without_restart() { + let guard = tempfile::tempdir().unwrap(); + let dir = guard.path().to_path_buf(); + std::mem::forget(guard); + let cfg = dir.join("config.yaml"); + std::fs::write( + &cfg, + concat!( + "version: \"1.0\"\n", + "settings:\n", + " auto_install_deps: false\n", + "agents: []\n", + ), + ) + .unwrap(); + let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]); + let mut app = App::from_cli(cli).unwrap(); + let mut p = app.paths.clone(); + p.install_dir = dir.join("agents"); + p.bin_dir = dir.join("agents").join("bin"); + p.log_dir = dir.join("logs"); + p.state_file = dir.join("state.json"); + p.probe_cache_file = dir.join("probe-cache.json"); + p.config_dir = Some(dir.join("config")); + app.paths = p; + // Agent absent de l état : pas running, pas de restart -> false. + assert!(!check_once(&app, "claude-code", false, false)); + // Agent inconnu avec restart : erreur loguée, false. + assert!(!check_once(&app, "inconnu", true, false)); + } +} diff --git a/src/help.rs b/src/help.rs index fdb2ea5..f0fb766 100644 --- a/src/help.rs +++ b/src/help.rs @@ -312,6 +312,26 @@ pub static HELP_SPECS: &[HelpSpec] = &[ HelpExample { desc: "Open the install directory.", code: "open claude-code" }, ], }, + HelpSpec { + name: "watch", + category: "Commands", + usage: "watch {flags} ", + about: "Supervise an agent and optionally restart it on crash.", + search_terms: &["monitor", "supervise", "restart"], + flags: &[ + HelpFlag { short: "", long: "--restart", value: "", desc: "Restart the agent automatically when it dies (with backoff)" }, + HelpFlag { short: "", long: "--notify", value: "", desc: "Desktop notification on every restart" }, + HelpFlag { short: "", long: "--interval", value: "SECS", desc: "Check interval in seconds (default: 5)" }, + ], + subcommands: &[], + parameters: &[ + HelpParam { name: "agent", typ: "string", desc: "Agent name or alias" }, + ], + io: None, + examples: &[ + HelpExample { desc: "Supervise with auto-restart.", code: "watch claude-code --restart --notify" }, + ], + }, HelpSpec { name: "alias", category: "Commands", diff --git a/src/repl.rs b/src/repl.rs index 79465bc..a77f6b2 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -70,6 +70,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[ ("init", "generate a project config"), ("alias", "manage aliases"), ("open", "open the install dir"), + ("watch", "supervise an agent"), ("search", "search the catalog"), ("info", "agent details"), ("install", "install an agent"), @@ -146,7 +147,7 @@ impl AmCompleter { Self { theme: std::cell::Cell::new(crate::theme::default_theme()), commands: vec![ - "list", "status", "sessions", "stats", "projects", "timeline", "log", "logs", "history", "init", "alias", "open", "search", "info", "install", "uninstall", "update", + "list", "status", "sessions", "stats", "projects", "timeline", "log", "logs", "history", "init", "alias", "open", "watch", "search", "info", "install", "uninstall", "update", "start", "stop", "restart", "run", "doctor", "config", "completion", "self-update", "self-uninstall", "export", "import", "shell", "theme", "ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit", @@ -1230,6 +1231,12 @@ fn handle_line( "open" => Command::Open { agent: need("agent name")?, }, + "watch" => Command::Watch { + agent: need("agent name")?, + restart: flag("--restart"), + notify: flag("--notify"), + interval: opt_value("--interval").and_then(|v| v.parse().ok()).unwrap_or(5), + }, "alias" => match rest.first().map(|s| s.as_str()) { Some("add") if rest.len() >= 3 => Command::Alias(crate::cli::AliasCmd::Add { name: rest[1].clone(), @@ -1398,6 +1405,7 @@ fn is_am_command(word: &str) -> bool { | "init" | "alias" | "open" + | "watch" | "search" | "info" | "install" -- 2.54.0