watch : supervision + redémarrage automatique (issue #35) #84

Merged
bruno merged 1 commits from feat/watch into main 2026-08-17 08:58:33 -04:00
5 changed files with 157 additions and 1 deletions
Showing only changes of commit 8d93eece1e - Show all commits
+14
View File
@@ -91,6 +91,20 @@ pub enum Command {
/// Agent name or alias /// Agent name or alias
agent: String, 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) /// Manage command aliases (cc -> claude-code)
#[command(subcommand)] #[command(subcommand)]
Alias(AliasCmd), Alias(AliasCmd),
+7
View File
@@ -25,6 +25,7 @@ pub mod status_cmd;
pub mod timeline_cmd; pub mod timeline_cmd;
pub mod uninstall_cmd; pub mod uninstall_cmd;
pub mod update_cmd; pub mod update_cmd;
pub mod watch_cmd;
pub mod version_cmd; pub mod version_cmd;
use crate::app::App; use crate::app::App;
@@ -57,6 +58,12 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
follow, follow,
} => logs_cmd::run(app, agent, *lines, *follow), } => logs_cmd::run(app, agent, *lines, *follow),
Command::Open { agent } => open_cmd::run(app, agent), 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::Alias(sub) => alias_cmd::run(app, sub),
Command::Start(a) => run_cmd::start(app, a), Command::Start(a) => run_cmd::start(app, a),
Command::Stop { Command::Stop {
+107
View File
@@ -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<i32> {
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));
}
}
+20
View File
@@ -312,6 +312,26 @@ pub static HELP_SPECS: &[HelpSpec] = &[
HelpExample { desc: "Open the install directory.", code: "open claude-code" }, HelpExample { desc: "Open the install directory.", code: "open claude-code" },
], ],
}, },
HelpSpec {
name: "watch",
category: "Commands",
usage: "watch {flags} <agent>",
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 { HelpSpec {
name: "alias", name: "alias",
category: "Commands", category: "Commands",
+9 -1
View File
@@ -70,6 +70,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("init", "generate a project config"), ("init", "generate a project config"),
("alias", "manage aliases"), ("alias", "manage aliases"),
("open", "open the install dir"), ("open", "open the install dir"),
("watch", "supervise an agent"),
("search", "search the catalog"), ("search", "search the catalog"),
("info", "agent details"), ("info", "agent details"),
("install", "install an agent"), ("install", "install an agent"),
@@ -146,7 +147,7 @@ impl AmCompleter {
Self { Self {
theme: std::cell::Cell::new(crate::theme::default_theme()), theme: std::cell::Cell::new(crate::theme::default_theme()),
commands: vec![ 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", "start", "stop", "restart", "run", "doctor", "config", "completion",
"self-update", "self-uninstall", "export", "import", "shell", "theme", "self-update", "self-uninstall", "export", "import", "shell", "theme",
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit", "ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
@@ -1230,6 +1231,12 @@ fn handle_line(
"open" => Command::Open { "open" => Command::Open {
agent: need("agent name")?, 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()) { "alias" => match rest.first().map(|s| s.as_str()) {
Some("add") if rest.len() >= 3 => Command::Alias(crate::cli::AliasCmd::Add { Some("add") if rest.len() >= 3 => Command::Alias(crate::cli::AliasCmd::Add {
name: rest[1].clone(), name: rest[1].clone(),
@@ -1398,6 +1405,7 @@ fn is_am_command(word: &str) -> bool {
| "init" | "init"
| "alias" | "alias"
| "open" | "open"
| "watch"
| "search" | "search"
| "info" | "info"
| "install" | "install"