sessions : am sessions --resume <id> (issue #32) - relance en arriere-plan avec les arguments et le cwd d'origine, cwd optionnel dans spawn_background, gardes running/repl/erreurs claires
This commit is contained in:
@@ -136,6 +136,9 @@ pub enum Command {
|
|||||||
/// Show one session in detail (summary + log excerpt)
|
/// Show one session in detail (summary + log excerpt)
|
||||||
#[arg(long, value_name = "ID")]
|
#[arg(long, value_name = "ID")]
|
||||||
show: Option<String>,
|
show: Option<String>,
|
||||||
|
/// Resume a finished session: relaunch the agent with its recorded arguments
|
||||||
|
#[arg(long, value_name = "ID")]
|
||||||
|
resume: Option<String>,
|
||||||
},
|
},
|
||||||
/// Show usage statistics computed from the event journal
|
/// Show usage statistics computed from the event journal
|
||||||
Stats {
|
Stats {
|
||||||
|
|||||||
@@ -89,12 +89,14 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
|||||||
project,
|
project,
|
||||||
status,
|
status,
|
||||||
show,
|
show,
|
||||||
|
resume,
|
||||||
} => sessions_cmd::run(
|
} => sessions_cmd::run(
|
||||||
app,
|
app,
|
||||||
agent.as_deref(),
|
agent.as_deref(),
|
||||||
project.as_deref(),
|
project.as_deref(),
|
||||||
status.as_deref(),
|
status.as_deref(),
|
||||||
show.as_deref(),
|
show.as_deref(),
|
||||||
|
resume.as_deref(),
|
||||||
),
|
),
|
||||||
Command::Install {
|
Command::Install {
|
||||||
agent,
|
agent,
|
||||||
|
|||||||
+19
-10
@@ -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<i32> {
|
pub fn start(app: &App, opts: &StartArgs) -> Result<i32> {
|
||||||
let cwd = std::env::current_dir().unwrap_or_default();
|
start_with_cwd(app, opts, None)
|
||||||
let target = resolve_start_target(app, &cwd, opts.agent.as_deref())?;
|
}
|
||||||
|
|
||||||
|
/// 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<i32> {
|
||||||
|
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_env = parse_env_list(&opts.env)?;
|
||||||
let extra_args = parse_extra_args(&opts.args);
|
let extra_args = parse_extra_args(&opts.args);
|
||||||
if let Some(group) = crate::catalog::Catalog::parse_group_selector(&target) {
|
if let Some(group) = crate::catalog::Catalog::parse_group_selector(&target) {
|
||||||
@@ -35,12 +40,12 @@ pub fn start(app: &App, opts: &StartArgs) -> Result<i32> {
|
|||||||
bail!("unknown group '{group}'");
|
bail!("unknown group '{group}'");
|
||||||
}
|
}
|
||||||
for m in members {
|
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);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
let agent = require_agent(app, &target)?;
|
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(
|
fn start_one(
|
||||||
@@ -50,13 +55,14 @@ fn start_one(
|
|||||||
extra_env: &BTreeMap<String, String>,
|
extra_env: &BTreeMap<String, String>,
|
||||||
notify: bool,
|
notify: bool,
|
||||||
background: bool,
|
background: bool,
|
||||||
|
cwd: Option<&std::path::Path>,
|
||||||
) -> Result<i32> {
|
) -> Result<i32> {
|
||||||
let exec = resolve_exec(app, agent, extra_args, extra_env)?;
|
let exec = resolve_exec(app, agent, extra_args, extra_env)?;
|
||||||
let bin = PathBuf::from(&exec.program);
|
let bin = PathBuf::from(&exec.program);
|
||||||
let is_managed = app.state.get(&agent.name).ok().flatten().is_some();
|
let is_managed = app.state.get(&agent.name).ok().flatten().is_some();
|
||||||
let ctx = crate::context::detect(&std::env::current_dir().unwrap_or_default());
|
let ctx = crate::context::detect(&std::env::current_dir().unwrap_or_default());
|
||||||
if background {
|
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() {
|
if app.dry_run() {
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
@@ -115,9 +121,12 @@ fn start_one(
|
|||||||
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
|
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
|
||||||
let mut full_args = prefix;
|
let mut full_args = prefix;
|
||||||
full_args.extend(exec.args.iter().cloned());
|
full_args.extend(exec.args.iter().cloned());
|
||||||
let status = Command::new(&prog)
|
let mut cmd = Command::new(&prog);
|
||||||
.args(&full_args)
|
cmd.args(&full_args).envs(&exec.env);
|
||||||
.envs(&exec.env)
|
if let Some(dir) = cwd {
|
||||||
|
cmd.current_dir(dir);
|
||||||
|
}
|
||||||
|
let status = cmd
|
||||||
.status()
|
.status()
|
||||||
.with_context(|| format!("failed to run {}", exec.program))?;
|
.with_context(|| format!("failed to run {}", exec.program))?;
|
||||||
let code = status.code().unwrap_or(1);
|
let code = status.code().unwrap_or(1);
|
||||||
@@ -225,7 +234,7 @@ pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option<u64>) -
|
|||||||
let extra_env = parse_env_list(&opts.env)?;
|
let extra_env = parse_env_list(&opts.env)?;
|
||||||
let extra_args = parse_extra_args(&opts.args);
|
let extra_args = parse_extra_args(&opts.args);
|
||||||
for m in members {
|
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);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
@@ -233,7 +242,7 @@ pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option<u64>) -
|
|||||||
stop_one(app, agent, force, timeout)?;
|
stop_one(app, agent, force, timeout)?;
|
||||||
let extra_env = parse_env_list(&opts.env)?;
|
let extra_env = parse_env_list(&opts.env)?;
|
||||||
let extra_args = parse_extra_args(&opts.args);
|
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.
|
/// run: execute the agent command directly, no process management.
|
||||||
|
|||||||
@@ -44,10 +44,14 @@ pub fn run(
|
|||||||
project: Option<&str>,
|
project: Option<&str>,
|
||||||
status: Option<&str>,
|
status: Option<&str>,
|
||||||
show: Option<&str>,
|
show: Option<&str>,
|
||||||
|
resume: Option<&str>,
|
||||||
) -> Result<i32> {
|
) -> Result<i32> {
|
||||||
if let Some(id) = show {
|
if let Some(id) = show {
|
||||||
return show_one(app, id);
|
return show_one(app, id);
|
||||||
}
|
}
|
||||||
|
if let Some(id) = resume {
|
||||||
|
return resume_session(app, id);
|
||||||
|
}
|
||||||
sessions::reconcile(app)?;
|
sessions::reconcile(app)?;
|
||||||
let sf = sessions::load(app)?;
|
let sf = sessions::load(app)?;
|
||||||
let mut rows: Vec<SessionRow> = sf
|
let mut rows: Vec<SessionRow> = sf
|
||||||
@@ -130,6 +134,48 @@ fn show_one(app: &App, id: &str) -> Result<i32> {
|
|||||||
Ok(0)
|
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<i32> {
|
||||||
|
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"
|
/// "20260815_143926_a1b2c3" -> "20260815_143926"
|
||||||
fn short_id(id: &str) -> String {
|
fn short_id(id: &str) -> String {
|
||||||
id.chars().take(15).collect()
|
id.chars().take(15).collect()
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ pub fn spawn_background(
|
|||||||
program: &Path,
|
program: &Path,
|
||||||
args: &[String],
|
args: &[String],
|
||||||
env: &BTreeMap<String, String>,
|
env: &BTreeMap<String, String>,
|
||||||
|
cwd: Option<&Path>,
|
||||||
) -> Result<u32> {
|
) -> Result<u32> {
|
||||||
let log = agent_log_path(app, agent_name);
|
let log = agent_log_path(app, agent_name);
|
||||||
if app.dry_run() {
|
if app.dry_run() {
|
||||||
@@ -121,6 +122,9 @@ pub fn spawn_background(
|
|||||||
.envs(env)
|
.envs(env)
|
||||||
.stdout(Stdio::from(file.try_clone()?))
|
.stdout(Stdio::from(file.try_clone()?))
|
||||||
.stderr(Stdio::from(file));
|
.stderr(Stdio::from(file));
|
||||||
|
if let Some(dir) = cwd {
|
||||||
|
cmd.current_dir(dir);
|
||||||
|
}
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::process::CommandExt;
|
use std::os::unix::process::CommandExt;
|
||||||
|
|||||||
@@ -1252,6 +1252,7 @@ fn handle_line(
|
|||||||
project: opt_value("--project"),
|
project: opt_value("--project"),
|
||||||
status: opt_value("--status"),
|
status: opt_value("--status"),
|
||||||
show: opt_value("--show"),
|
show: opt_value("--show"),
|
||||||
|
resume: opt_value("--resume"),
|
||||||
},
|
},
|
||||||
"stats" => Command::Stats {
|
"stats" => Command::Stats {
|
||||||
agent: rest.first().cloned(),
|
agent: rest.first().cloned(),
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ fn spawn_detect_stop_cycle() {
|
|||||||
std::path::Path::new(prog),
|
std::path::Path::new(prog),
|
||||||
&args,
|
&args,
|
||||||
&env,
|
&env,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(pid > 0);
|
assert!(pid > 0);
|
||||||
@@ -57,6 +58,7 @@ fn spawning_a_failing_program_reports_an_error() {
|
|||||||
std::path::Path::new(prog),
|
std::path::Path::new(prog),
|
||||||
&args,
|
&args,
|
||||||
&env,
|
&env,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
+19
-2
@@ -58,16 +58,33 @@ fn agent_session_roundtrip_and_counter() {
|
|||||||
fn command_lists_sessions() {
|
fn command_lists_sessions() {
|
||||||
let app = common::test_app("sesscmd", &["sessions"]);
|
let app = common::test_app("sesscmd", &["sessions"]);
|
||||||
sessions::start_agent(&app, "aider", 4242, None, None, vec![], vec![], None).unwrap();
|
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);
|
assert_eq!(code, 0);
|
||||||
// --json path returns the row set without error.
|
// --json path returns the row set without error.
|
||||||
sessions::finish_agent(&app, "aider", Some(4242), 0, true).unwrap();
|
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]
|
#[test]
|
||||||
fn show_one_accepts_known_id() {
|
fn show_one_accepts_known_id() {
|
||||||
let app = common::test_app("sessshow", &["sessions"]);
|
let app = common::test_app("sessshow", &["sessions"]);
|
||||||
let id = sessions::start_agent(&app, "jcode", 99, None, None, vec![], vec![], None).unwrap();
|
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);
|
assert_eq!(code, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user