From 30cc49de3cbffe310f1a2d49e347233867490d55 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 17 Aug 2026 08:48:30 -0400 Subject: [PATCH] sessions : am sessions --resume (issue #32) - relance en arriere-plan avec les arguments et le cwd d'origine, cwd optionnel dans spawn_background, gardes running/repl/erreurs claires --- src/cli.rs | 3 +++ src/commands/mod.rs | 2 ++ src/commands/run_cmd.rs | 29 +++++++++++++++-------- src/commands/sessions_cmd.rs | 46 ++++++++++++++++++++++++++++++++++++ src/process.rs | 4 ++++ src/repl.rs | 1 + tests/process_test.rs | 2 ++ tests/sessions_test.rs | 21 ++++++++++++++-- 8 files changed, 96 insertions(+), 12 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 134f6a6..afa6546 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -136,6 +136,9 @@ pub enum Command { /// Show one session in detail (summary + log excerpt) #[arg(long, value_name = "ID")] show: Option, + /// Resume a finished session: relaunch the agent with its recorded arguments + #[arg(long, value_name = "ID")] + resume: Option, }, /// Show usage statistics computed from the event journal Stats { diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 0283268..fa91217 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -89,12 +89,14 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { project, status, show, + resume, } => sessions_cmd::run( app, agent.as_deref(), project.as_deref(), status.as_deref(), show.as_deref(), + resume.as_deref(), ), Command::Install { agent, diff --git a/src/commands/run_cmd.rs b/src/commands/run_cmd.rs index aae8e4e..cd022f3 100644 --- a/src/commands/run_cmd.rs +++ b/src/commands/run_cmd.rs @@ -25,8 +25,13 @@ pub fn resolve_start_target(app: &App, cwd: &std::path::Path, agent: Option<&str } pub fn start(app: &App, opts: &StartArgs) -> Result { - let cwd = std::env::current_dir().unwrap_or_default(); - let target = resolve_start_target(app, &cwd, opts.agent.as_deref())?; + start_with_cwd(app, opts, None) +} + +/// Start with an explicit working directory (used by sessions resume). +pub fn start_with_cwd(app: &App, opts: &StartArgs, cwd: Option<&std::path::Path>) -> Result { + let current = std::env::current_dir().unwrap_or_default(); + let target = resolve_start_target(app, ¤t, opts.agent.as_deref())?; let extra_env = parse_env_list(&opts.env)?; let extra_args = parse_extra_args(&opts.args); if let Some(group) = crate::catalog::Catalog::parse_group_selector(&target) { @@ -35,12 +40,12 @@ pub fn start(app: &App, opts: &StartArgs) -> Result { bail!("unknown group '{group}'"); } for m in members { - start_one(app, m, &extra_args, &extra_env, opts.notify, true)?; + start_one(app, m, &extra_args, &extra_env, opts.notify, true, cwd)?; } return Ok(0); } let agent = require_agent(app, &target)?; - start_one(app, agent, &extra_args, &extra_env, opts.notify, opts.background) + start_one(app, agent, &extra_args, &extra_env, opts.notify, opts.background, cwd) } fn start_one( @@ -50,13 +55,14 @@ fn start_one( extra_env: &BTreeMap, notify: bool, background: bool, + cwd: Option<&std::path::Path>, ) -> Result { let exec = resolve_exec(app, agent, extra_args, extra_env)?; let bin = PathBuf::from(&exec.program); let is_managed = app.state.get(&agent.name).ok().flatten().is_some(); let ctx = crate::context::detect(&std::env::current_dir().unwrap_or_default()); if background { - let pid = process::spawn_background(app, &agent.name, &bin, &exec.args, &exec.env)?; + let pid = process::spawn_background(app, &agent.name, &bin, &exec.args, &exec.env, cwd)?; if app.dry_run() { return Ok(0); } @@ -115,9 +121,12 @@ fn start_one( let (prog, prefix) = crate::runner::resolve_program(&exec.program); let mut full_args = prefix; full_args.extend(exec.args.iter().cloned()); - let status = Command::new(&prog) - .args(&full_args) - .envs(&exec.env) + let mut cmd = Command::new(&prog); + cmd.args(&full_args).envs(&exec.env); + if let Some(dir) = cwd { + cmd.current_dir(dir); + } + let status = cmd .status() .with_context(|| format!("failed to run {}", exec.program))?; let code = status.code().unwrap_or(1); @@ -225,7 +234,7 @@ pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option) - let extra_env = parse_env_list(&opts.env)?; let extra_args = parse_extra_args(&opts.args); for m in members { - start_one(app, m, &extra_args, &extra_env, opts.notify, true)?; + start_one(app, m, &extra_args, &extra_env, opts.notify, true, None)?; } return Ok(0); } @@ -233,7 +242,7 @@ pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option) - stop_one(app, agent, force, timeout)?; let extra_env = parse_env_list(&opts.env)?; let extra_args = parse_extra_args(&opts.args); - start_one(app, agent, &extra_args, &extra_env, opts.notify, opts.background) + start_one(app, agent, &extra_args, &extra_env, opts.notify, opts.background, None) } /// run: execute the agent command directly, no process management. diff --git a/src/commands/sessions_cmd.rs b/src/commands/sessions_cmd.rs index 8316cdf..16fcb95 100644 --- a/src/commands/sessions_cmd.rs +++ b/src/commands/sessions_cmd.rs @@ -44,10 +44,14 @@ pub fn run( project: Option<&str>, status: Option<&str>, show: Option<&str>, + resume: Option<&str>, ) -> Result { if let Some(id) = show { return show_one(app, id); } + if let Some(id) = resume { + return resume_session(app, id); + } sessions::reconcile(app)?; let sf = sessions::load(app)?; let mut rows: Vec = sf @@ -130,6 +134,48 @@ fn show_one(app: &App, id: &str) -> Result { Ok(0) } +/// Resume a finished session: relaunch the agent in the background with +/// its recorded arguments and working directory (issue #32). +fn resume_session(app: &App, id: &str) -> Result { + sessions::reconcile(app)?; + let sf = sessions::load(app)?; + let Some(r) = sf.sessions.get(id).cloned() else { + bail!("unknown session '{id}' — 'am sessions' lists every session"); + }; + if r.kind != "agent" { + bail!("session '{id}' is a REPL session — start it again with 'am'"); + } + if r.status == "running" { + app.log.info(&format!("session '{id}' is still running")); + return Ok(0); + } + let Some(agent_name) = r.agent.clone() else { + bail!("session '{id}' has no agent"); + }; + require_agent(app, &agent_name)?; + if app.dry_run() { + app.log.dry(format!( + "would resume session {id}: start {} --background with {} arg(s)", + agent_name, + r.args.len() + )); + return Ok(0); + } + let cwd = r.cwd.as_deref().map(std::path::Path::new); + let opts = crate::cli::StartArgs { + agent: Some(agent_name.clone()), + background: true, + args: r.args.clone(), + ..Default::default() + }; + app.log.info(&format!( + "resuming session {id}: {} ({} arg(s))", + agent_name, + r.args.len() + )); + crate::commands::run_cmd::start_with_cwd(app, &opts, cwd) +} + /// "20260815_143926_a1b2c3" -> "20260815_143926" fn short_id(id: &str) -> String { id.chars().take(15).collect() diff --git a/src/process.rs b/src/process.rs index 2be57e9..dc1c86a 100644 --- a/src/process.rs +++ b/src/process.rs @@ -98,6 +98,7 @@ pub fn spawn_background( program: &Path, args: &[String], env: &BTreeMap, + cwd: Option<&Path>, ) -> Result { let log = agent_log_path(app, agent_name); if app.dry_run() { @@ -121,6 +122,9 @@ pub fn spawn_background( .envs(env) .stdout(Stdio::from(file.try_clone()?)) .stderr(Stdio::from(file)); + if let Some(dir) = cwd { + cmd.current_dir(dir); + } #[cfg(unix)] { use std::os::unix::process::CommandExt; diff --git a/src/repl.rs b/src/repl.rs index 2213a5a..79465bc 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -1252,6 +1252,7 @@ fn handle_line( project: opt_value("--project"), status: opt_value("--status"), show: opt_value("--show"), + resume: opt_value("--resume"), }, "stats" => Command::Stats { agent: rest.first().cloned(), diff --git a/tests/process_test.rs b/tests/process_test.rs index 31f1573..1f6ab3c 100644 --- a/tests/process_test.rs +++ b/tests/process_test.rs @@ -31,6 +31,7 @@ fn spawn_detect_stop_cycle() { std::path::Path::new(prog), &args, &env, + None, ) .unwrap(); assert!(pid > 0); @@ -57,6 +58,7 @@ fn spawning_a_failing_program_reports_an_error() { std::path::Path::new(prog), &args, &env, + None, ) .unwrap_err(); assert!( diff --git a/tests/sessions_test.rs b/tests/sessions_test.rs index 61412d8..ec218ca 100644 --- a/tests/sessions_test.rs +++ b/tests/sessions_test.rs @@ -58,16 +58,33 @@ fn agent_session_roundtrip_and_counter() { fn command_lists_sessions() { let app = common::test_app("sesscmd", &["sessions"]); sessions::start_agent(&app, "aider", 4242, None, None, vec![], vec![], None).unwrap(); - let code = sessions_cmd::run(&app, None, None, None, None).unwrap(); + let code = sessions_cmd::run(&app, None, None, None, None, None).unwrap(); assert_eq!(code, 0); // --json path returns the row set without error. sessions::finish_agent(&app, "aider", Some(4242), 0, true).unwrap(); } +#[test] +fn resume_unknown_or_repl_session_errors() { + let app = common::test_app("resume", &["sessions"]); + assert!(sessions_cmd::run(&app, None, None, None, None, Some("inconnue")).is_err()); + sessions::start_repl(&app, "r1", None).unwrap(); + assert!(sessions_cmd::run(&app, None, None, None, None, Some("r1")).is_err()); +} + +#[test] +fn resume_dry_run_announces() { + let app = common::test_app("resumedry", &["sessions", "--dry-run"]); + let id = sessions::start_agent(&app, "claude-code", 4242, None, None, vec![], vec![], None).unwrap(); + sessions::finish_agent(&app, "claude-code", Some(4242), 0, true).unwrap(); + let code = sessions_cmd::run(&app, None, None, None, None, Some(&id)).unwrap(); + assert_eq!(code, 0, "dry-run resume"); +} + #[test] fn show_one_accepts_known_id() { let app = common::test_app("sessshow", &["sessions"]); let id = sessions::start_agent(&app, "jcode", 99, None, None, vec![], vec![], None).unwrap(); - let code = sessions_cmd::run(&app, None, None, None, Some(&id)).unwrap(); + let code = sessions_cmd::run(&app, None, None, None, Some(&id), None).unwrap(); assert_eq!(code, 0); }