Merge pull request 'sessions : am sessions --resume (issue #32)' (#82) from feat/sessions-resume into main

This commit was merged in pull request #82.
This commit is contained in:
2026-08-17 08:48:45 -04:00
8 changed files with 96 additions and 12 deletions
+3
View File
@@ -136,6 +136,9 @@ pub enum Command {
/// Show one session in detail (summary + log excerpt)
#[arg(long, value_name = "ID")]
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
Stats {
+2
View File
@@ -89,12 +89,14 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
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,
+19 -10
View File
@@ -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> {
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<i32> {
let current = std::env::current_dir().unwrap_or_default();
let target = resolve_start_target(app, &current, 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<i32> {
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<String, String>,
notify: bool,
background: bool,
cwd: Option<&std::path::Path>,
) -> Result<i32> {
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<u64>) -
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<u64>) -
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.
+46
View File
@@ -44,10 +44,14 @@ pub fn run(
project: Option<&str>,
status: Option<&str>,
show: Option<&str>,
resume: Option<&str>,
) -> Result<i32> {
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<SessionRow> = sf
@@ -130,6 +134,48 @@ fn show_one(app: &App, id: &str) -> Result<i32> {
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"
fn short_id(id: &str) -> String {
id.chars().take(15).collect()
+4
View File
@@ -98,6 +98,7 @@ pub fn spawn_background(
program: &Path,
args: &[String],
env: &BTreeMap<String, String>,
cwd: Option<&Path>,
) -> Result<u32> {
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;
+1
View File
@@ -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(),
+2
View File
@@ -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!(
+19 -2
View File
@@ -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);
}