From 6ac137d43257b10c32c1dc3e5c947b984fcfe2bf Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Fri, 4 Sep 2026 08:48:30 +0100 Subject: [PATCH 1/2] feat(maestro): add the resume route path and preset command builder The template's {agent_session} is filled from the session's own conversation id; a template that asks for an id the session never reported yields nothing rather than a `--resume` with no argument. --- crates/maestro-protocol/src/lib.rs | 64 +++++++++++++++++++++++++++ crates/maestro-protocol/src/routes.rs | 15 +++++++ 2 files changed, 79 insertions(+) diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 7e83d5f..9748e62 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -536,6 +536,29 @@ pub struct SpawnPreset { pub resume_command: Option, } +/// Placeholder a preset's `resume_command` may carry for the agent's own +/// conversation id (#165), substituted from [`Session::agent_session_id`]. +pub const AGENT_SESSION_PLACEHOLDER: &str = "{agent_session}"; + +impl SpawnPreset { + /// The command that resumes a session spawned from this preset, with + /// [`AGENT_SESSION_PLACEHOLDER`] filled in from `agent_session`. + /// + /// `None` when the preset has no `resume_command` — a plain shell has + /// nothing to resume — and also when the template asks for an agent + /// session id the session never reported: `claude --resume` with an empty + /// id resumes nothing, so the caller is better off respawning the original + /// command than running a broken one. + pub fn resume_command_for(&self, agent_session: Option<&str>) -> Option { + let template = self.resume_command.as_deref()?; + if !template.contains(AGENT_SESSION_PLACEHOLDER) { + return Some(template.to_string()); + } + let id = agent_session.filter(|s| !s.is_empty())?; + Some(template.replace(AGENT_SESSION_PLACEHOLDER, id)) + } +} + /// Build provenance of the running daemon: the commit it was compiled /// from and when that commit was authored. /// @@ -785,6 +808,47 @@ pub enum ClientControl { mod tests { use super::*; + fn preset(resume: Option<&str>) -> SpawnPreset { + SpawnPreset { + id: "claude".into(), + label: "Claude".into(), + command: "claude".into(), + resume_command: resume.map(str::to_string), + } + } + + #[test] + fn resume_command_substitutes_the_agent_session() { + let p = preset(Some("claude --resume {agent_session}")); + assert_eq!( + p.resume_command_for(Some("abc-123")).as_deref(), + Some("claude --resume abc-123") + ); + } + + #[test] + fn resume_command_without_a_placeholder_ignores_the_agent_session() { + let p = preset(Some("opencode --continue")); + assert_eq!( + p.resume_command_for(None).as_deref(), + Some("opencode --continue") + ); + } + + /// A template that wants an id the session never reported would run + /// `claude --resume` against nothing; the caller respawns instead. + #[test] + fn resume_command_is_none_without_the_id_it_asks_for() { + let p = preset(Some("claude --resume {agent_session}")); + assert!(p.resume_command_for(None).is_none()); + assert!(p.resume_command_for(Some("")).is_none()); + } + + #[test] + fn a_preset_without_a_resume_command_has_none() { + assert!(preset(None).resume_command_for(Some("abc")).is_none()); + } + #[test] fn version_compatibility_is_major_only() { assert!(is_compatible(PROTOCOL_VERSION)); diff --git a/crates/maestro-protocol/src/routes.rs b/crates/maestro-protocol/src/routes.rs index 1594df8..0a4baf4 100644 --- a/crates/maestro-protocol/src/routes.rs +++ b/crates/maestro-protocol/src/routes.rs @@ -66,6 +66,12 @@ pub fn session_focus(id: Uuid) -> String { format!("{SESSIONS}/{id}/focus") } +/// `POST /api/sessions/:id/resume` (#166): respawn a dormant session under its +/// own id, keeping its sidecar, name, worktree and `created_at`. +pub fn session_resume(id: Uuid) -> String { + format!("{SESSIONS}/{id}/resume") +} + /// `WS /api/attach/:id` — raw PTY bytes. pub fn attach(id: Uuid) -> String { format!("{API_PREFIX}/attach/{id}") @@ -79,6 +85,7 @@ pub mod patterns { pub const SESSION_INPUT: &str = "/api/sessions/:id/input"; pub const SESSION_PROMPT: &str = "/api/sessions/:id/prompt"; pub const SESSION_FOCUS: &str = "/api/sessions/:id/focus"; + pub const SESSION_RESUME: &str = "/api/sessions/:id/resume"; pub const ATTACH: &str = "/api/attach/:id"; } @@ -202,6 +209,10 @@ mod tests { session_focus(id()), "/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/focus" ); + assert_eq!( + session_resume(id()), + "/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/resume" + ); assert_eq!( attach(id()), "/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50" @@ -236,6 +247,10 @@ mod tests { patterns::SESSION_FOCUS.trim_end_matches(":id/focus"), session(id()).trim_end_matches(&id().to_string()) ); + assert_eq!( + patterns::SESSION_RESUME.trim_end_matches(":id/resume"), + session(id()).trim_end_matches(&id().to_string()) + ); assert_eq!( patterns::ATTACH.trim_end_matches(":id"), attach(id()).trim_end_matches(&id().to_string()) From bb6b885c6806c6e662165d90c051f69a49094ce6 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Fri, 4 Sep 2026 08:53:06 +0100 Subject: [PATCH 2/2] feat(maestro): add POST /api/sessions/:id/resume A dormant session comes back under the same uuid and sidecar, so its name, created_at, env and worktree ownership survive; only the tmux session and the PTY bridge are new. The command is the matching preset's resume_command with {agent_session} filled in, falling back to the command the session was spawned with. Refused with 409 for a session that is already running or whose cwd is gone. Attaching to a session with no bridge now answers 409 not_attachable instead of 404 unknown_session, so a client can tell a resumable session from one that does not exist. --- CLAUDE.md | 1 + crates/maestro-daemon/src/lib.rs | 1 + crates/maestro-daemon/src/local_api.rs | 35 +++ crates/maestro-daemon/src/resume.rs | 99 +++++++ crates/maestro-daemon/src/session.rs | 117 ++++++++ crates/maestro-daemon/tests/attach.rs | 68 +++++ crates/maestro-daemon/tests/resume.rs | 359 +++++++++++++++++++++++++ docs/RESTART.md | 23 +- 8 files changed, 700 insertions(+), 3 deletions(-) create mode 100644 crates/maestro-daemon/src/resume.rs create mode 100644 crates/maestro-daemon/tests/resume.rs diff --git a/CLAUDE.md b/CLAUDE.md index 3aa5054..90306ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,7 @@ maestro daemon tmux wrapper + session API + SPA on 127.0.0.1:7070 ├── DELETE /api/sessions/:id kill ├── POST /api/sessions/:id/input type text into a session ├── POST /api/sessions/:id/prompt an agent reports its first prompt (#131) + ├── POST /api/sessions/:id/resume respawn a dormant session, same id (#166) ├── GET /api/info hostname, version, home dir, spawn presets ├── GET /api/dirs directory listing (spawn autocomplete) ├── GET /api/recent-dirs spawn history diff --git a/crates/maestro-daemon/src/lib.rs b/crates/maestro-daemon/src/lib.rs index c4f8031..2ea4aa1 100644 --- a/crates/maestro-daemon/src/lib.rs +++ b/crates/maestro-daemon/src/lib.rs @@ -25,6 +25,7 @@ pub mod proc; pub mod projects; pub mod pty; pub mod release; +pub mod resume; pub mod session; pub mod sidecar; pub mod spawn_history; diff --git a/crates/maestro-daemon/src/local_api.rs b/crates/maestro-daemon/src/local_api.rs index 24eeb0f..eaba408 100644 --- a/crates/maestro-daemon/src/local_api.rs +++ b/crates/maestro-daemon/src/local_api.rs @@ -52,6 +52,7 @@ pub fn router(state: LocalApiState) -> Router { .route(routes::patterns::SESSION_ACTIVITY, post(report_activity)) .route(routes::patterns::SESSION_INPUT, post(send_input)) .route(routes::patterns::SESSION_FOCUS, post(set_focus)) + .route(routes::patterns::SESSION_RESUME, post(resume_session)) .route( routes::patterns::SESSION, delete(kill_session).patch(rename_session), @@ -170,6 +171,9 @@ fn session_error_status(code: &str) -> StatusCode { // The session outlived its pane: nothing to type into, and nothing // the daemon can do about it. "not_running" => StatusCode::CONFLICT, + // Resume refusals (#166): the session exists and the request was + // well-formed, it just cannot be brought back in this state. + "already_running" | "cwd_missing" => StatusCode::CONFLICT, _ => StatusCode::BAD_GATEWAY, } } @@ -368,6 +372,27 @@ async fn set_focus( } } +/// `POST /api/sessions/:id/resume` (#166): respawn a session that has no pane +/// under its own id and sidecar, so its name, `created_at`, env and worktree +/// ownership survive. Answers with the updated session, which also goes out as +/// `SessionUpdated` to every events subscriber. +/// +/// Refused with `409` when the session is already running or its `cwd` is +/// gone; `404` when there is no such session. +async fn resume_session(State(s): State, Path(id): Path) -> Response { + match s.manager.resume(id, &s.presets).await { + Ok(session) => (StatusCode::OK, Json(session)).into_response(), + Err(e) => { + let info = e.to_error_info(); + error_response( + session_error_status(&info.code), + info.code.as_str(), + info.message.as_str(), + ) + } + } +} + fn error_response(status: StatusCode, code: &str, message: &str) -> Response { (status, Json(ApiError::new(code, message))).into_response() } @@ -410,6 +435,16 @@ async fn attach_ws( ws: axum::extract::WebSocketUpgrade, ) -> Result { let Some((replay, rx)) = s.manager.subscribe_pty_with_replay(id).await else { + // #166: a session the daemon knows but has no bridge for — a dormant + // one — is not missing, it is not running. Reporting it as unknown + // left the client unable to tell "gone" from "resumable". + if s.manager.has(id).await { + return Err(error_response( + StatusCode::CONFLICT, + "not_attachable", + "session has no live pane; resume it first", + )); + } return Err(error_response( StatusCode::NOT_FOUND, "unknown_session", diff --git a/crates/maestro-daemon/src/resume.rs b/crates/maestro-daemon/src/resume.rs new file mode 100644 index 0000000..8008adb --- /dev/null +++ b/crates/maestro-daemon/src/resume.rs @@ -0,0 +1,99 @@ +//! Choosing the command a resumed session comes back on (#166). +//! +//! A session records the command it was spawned with. Bringing it back means +//! running that preset's `resume_command` (#165) instead, so an agent picks up +//! its old conversation rather than starting a fresh one. Everything else +//! about the session — id, sidecar, name, `created_at`, env, worktree — is +//! untouched, so only the command is decided here. + +use maestro_protocol::{Session, SpawnPreset}; + +/// The command to respawn `session` with. +/// +/// The preset is matched on the command the session was spawned with, which is +/// what the sidecar holds. A session with no matching preset, a preset with no +/// `resume_command`, or a `resume_command` needing an agent session id the +/// session never reported all fall back to the original command: respawning +/// what the operator asked for is always honest, where a half-filled resume +/// template is not. +pub fn resume_command(session: &Session, presets: &[SpawnPreset]) -> String { + presets + .iter() + .find(|p| p.command == session.command) + .and_then(|p| p.resume_command_for(session.agent_session_id.as_deref())) + .unwrap_or_else(|| session.command.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn presets() -> Vec { + vec![ + SpawnPreset { + id: "claude".into(), + label: "Claude".into(), + command: "claude".into(), + resume_command: Some("claude --resume {agent_session}".into()), + }, + SpawnPreset { + id: "zsh".into(), + label: "Shell".into(), + command: "zsh".into(), + resume_command: None, + }, + ] + } + + fn session(command: &str, agent_session_id: Option<&str>) -> Session { + Session { + id: uuid::Uuid::new_v4(), + cwd: "/tmp".into(), + command: command.into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + status: maestro_protocol::SessionStatus::Dormant, + created_at: chrono::Utc::now(), + foreground: None, + activity: maestro_protocol::ActivityState::Idle, + activity_detail: None, + last_activity_at: chrono::Utc::now(), + activity_since: chrono::Utc::now(), + current_cwd: None, + blocked_work: None, + last_focused_at: None, + agent_session_id: agent_session_id.map(str::to_string), + } + } + + #[test] + fn uses_the_matching_presets_resume_command() { + assert_eq!( + resume_command(&session("claude", Some("conv-7")), &presets()), + "claude --resume conv-7" + ); + } + + #[test] + fn falls_back_when_the_agent_session_id_is_unknown() { + assert_eq!( + resume_command(&session("claude", None), &presets()), + "claude" + ); + } + + #[test] + fn falls_back_for_a_preset_without_a_resume_command() { + assert_eq!(resume_command(&session("zsh", None), &presets()), "zsh"); + } + + #[test] + fn falls_back_when_no_preset_matches() { + assert_eq!( + resume_command(&session("bash --noprofile", Some("conv-7")), &presets()), + "bash --noprofile" + ); + } +} diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index cc8492b..85f3862 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -112,6 +112,15 @@ pub enum SessionError { /// a stray keystroke on an attach socket, which is dropped. #[error("session is no longer running: {0}")] NotRunning(Uuid), + /// A resume (#166) was asked for a session that is already running. There + /// is nothing to bring back, and respawning would strand the pane that is + /// already there. + #[error("session is already running: {0}")] + AlreadyRunning(Uuid), + /// A resume (#166) was asked for a session whose working directory is + /// gone. Nothing can be resumed in a directory that no longer exists. + #[error("session working directory no longer exists: {0}")] + CwdMissing(PathBuf), /// The kill was refused (#79): the session's worktree holds unsaved work. /// Nothing happened — the session keeps running and the tree is untouched, /// so it stays the operator's handle on their own unfinished work. @@ -130,6 +139,8 @@ impl SessionError { SessionError::InvalidEnv(_) => "invalid_env", SessionError::NotRunning(_) => "not_running", SessionError::KillGuarded(_) => "kill_guarded", + SessionError::AlreadyRunning(_) => "already_running", + SessionError::CwdMissing(_) => "cwd_missing", }; let unsaved_work = match self { SessionError::KillGuarded(b) => Some(*b), @@ -586,6 +597,112 @@ impl SessionManager { Ok(session) } + /// Bring a session that has no pane back to life (#166). + /// + /// The session comes back under the **same** uuid and the same sidecar, so + /// `metadata.name`, `created_at`, `env` and worktree ownership all survive + /// — only the tmux session and the PTY bridge are new. That is the whole + /// point: spawning a fresh session at the same path instead produces a + /// stranger with no worktree to clean up on kill. + /// + /// The command is [`crate::resume::resume_command`]: the matching preset's + /// `resume_command` (#165) with the session's own agent conversation id + /// substituted in, falling back to the command the session was spawned + /// with. `Session::command` keeps the original either way, so a second + /// resume still resolves the same preset. + /// + /// Refused for a session that is already running, and for one whose `cwd` + /// has disappeared — neither is something a respawn could fix. + pub async fn resume( + &self, + id: Uuid, + presets: &[maestro_protocol::SpawnPreset], + ) -> Result { + let mut session = { + let sessions = self.inner.sessions.lock().await; + let rec = sessions.get(&id).ok_or(SessionError::NotFound(id))?; + if rec.session.status == SessionStatus::Running { + return Err(SessionError::AlreadyRunning(id)); + } + rec.session.clone() + }; + if !session.cwd.exists() { + return Err(SessionError::CwdMissing(session.cwd.clone())); + } + let command = crate::resume::resume_command(&session, presets); + + // Same fifo path as the original spawn: it is derived from the id, and + // `mkfifo` tolerates the stale node the previous run left behind. + let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo")); + let bridge = + PtyBridge::start_with_replay(fifo_path.clone(), self.inner.replay_bytes).await?; + let mut session_env = sanitize_env(&session.env)?; + session_env.insert("MAESTRO_SESSION_ID".into(), id.to_string()); + if let Some(url) = self + .inner + .daemon_url + .lock() + .expect("daemon_url mutex") + .clone() + { + session_env.insert("MAESTRO_URL".into(), url); + } + let spawned = { + let tmux = self.inner.tmux.clone(); + let cwd = session.cwd.clone(); + let command = command.clone(); + let sink = pipe_pane_command(&fifo_path); + tmux_blocking(move || tmux.new_session_piped(id, &cwd, &command, &session_env, &sink)) + .await + }; + if let Err(e) = spawned { + // `bridge` drops here, closing the FIFO reader. Nothing else was + // touched, so the session stays exactly as dormant as it was. + return Err(SessionError::Tmux(e)); + } + + session.status = SessionStatus::Running; + // A resumed session is producing its initial output again; the + // watcher refines this on its next tick, exactly as after a spawn. + session.foreground = None; + session.activity = ActivityState::Active; + session.activity_detail = None; + session.last_activity_at = Utc::now(); + session.activity_since = Utc::now(); + session.current_cwd = None; + session.last_focused_at = None; + + if let Err(e) = self.inner.sidecar.write(&session) { + // tmux already spawned; kill it rather than leave a pane the + // sidecar does not describe. + let tmux = self.inner.tmux.clone(); + let _ = tmux_blocking(move || tmux.kill_session(id)).await; + return Err(SessionError::Sidecar(e)); + } + + { + let mut sessions = self.inner.sessions.lock().await; + let Some(rec) = sessions.get_mut(&id) else { + // Killed while we were spawning. Undo the pane instead of + // leaving one behind with no record of it. + let tmux = self.inner.tmux.clone(); + let _ = tmux_blocking(move || tmux.kill_session(id)).await; + return Err(SessionError::NotFound(id)); + }; + rec.session = session.clone(); + rec.fifo_path = fifo_path; + rec.bridge = Some(bridge); + rec.reported_activity = None; + rec.settled = false; + rec.focused_since = None; + rec.awaiting_suppressed = false; + } + let _ = self.inner.events.send(CatalogEvent::SessionUpdated { + session: session.clone(), + }); + Ok(session) + } + /// Best-effort removal of a worktree created earlier in `create` when a /// later step fails. No-op when the request had no worktree. fn rollback_worktree(&self, base: &Option, path: &Option) { diff --git a/crates/maestro-daemon/tests/attach.rs b/crates/maestro-daemon/tests/attach.rs index 1cd9f53..e4c19b5 100644 --- a/crates/maestro-daemon/tests/attach.rs +++ b/crates/maestro-daemon/tests/attach.rs @@ -94,6 +94,74 @@ async fn rejects_unknown_session() { assert!(format!("{err}").contains("404")); } +/// #166: a session the daemon knows but has no bridge for is not unknown, it +/// is not running. `404 unknown_session` made "gone" and "resumable" look the +/// same to a client that could have offered a resume. +#[tokio::test] +async fn dormant_session_is_not_attachable_rather_than_unknown() { + let h = spawn_daemon_ws().await; + let cwd = TempDir::new().unwrap(); + let session = h + .manager + .create(CreateSessionRequest { + cwd: cwd.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + }) + .await + .unwrap(); + + // The host reboot: tmux is gone, the sidecar is not, and a fresh manager + // registers the session dormant. + h._fx.tmux.tmux.kill_server().unwrap(); + let dormant = SessionManager::new( + std::sync::Arc::new(h._fx.tmux.tmux.clone()), + maestro_daemon::sidecar::SidecarStore::new(h._fx.sidecar_dir()), + maestro_daemon::worktree::WorktreeManager::new(None), + h._fx.fifo_dir(), + ); + dormant.recover().await.unwrap(); + let dormant_addr = serve_manager(dormant).await; + + let err = connect_attach(dormant_addr, session.id).await.unwrap_err(); + let tokio_tungstenite::tungstenite::Error::Http(resp) = err else { + panic!("want an HTTP rejection, got {err}"); + }; + assert_eq!(resp.status().as_u16(), 409); + let body: maestro_protocol::ApiError = + serde_json::from_slice(resp.body().as_deref().expect("error body")).unwrap(); + assert_eq!(body.error.code, "not_attachable"); + + // A session that really is unknown still reads as unknown. + let err = connect_attach(dormant_addr, Uuid::new_v4()) + .await + .unwrap_err(); + assert!(format!("{err}").contains("404")); +} + +/// Serve `manager` on its own listener, for tests that need a second daemon +/// over the same tmux/sidecar state. +async fn serve_manager(manager: SessionManager) -> SocketAddr { + let router = local_api::router(local_api::state_for( + manager, + "test-host", + "/home/test", + Vec::new(), + std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), + maestro_config::DEFAULT_LEADER, + )); + let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) + .await + .unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + addr +} + #[tokio::test] async fn pty_round_trip_through_real_tmux() { let h = spawn_daemon_ws().await; diff --git a/crates/maestro-daemon/tests/resume.rs b/crates/maestro-daemon/tests/resume.rs new file mode 100644 index 0000000..f9fdcdc --- /dev/null +++ b/crates/maestro-daemon/tests/resume.rs @@ -0,0 +1,359 @@ +//! End-to-end tests for `POST /api/sessions/:id/resume` (#166) and the +//! manager call behind it, against real tmux. +//! +//! The point of resume is that the session comes back under the *same* uuid +//! and sidecar — name, `created_at`, env and worktree ownership intact — with +//! only the tmux session and the PTY bridge new. + +mod common; + +use std::collections::HashMap; +use std::net::{Ipv4Addr, SocketAddr}; +use std::sync::Arc; +use std::time::Duration; + +use common::{wait_for_async, DaemonFixture}; +use maestro_daemon::{local_api, SessionError, SessionManager}; +use maestro_protocol::{CreateSessionRequest, Session, SessionStatus, SpawnPreset}; +use tempfile::TempDir; +use tokio::net::TcpListener; +use tokio::sync::oneshot; +use uuid::Uuid; + +/// A fresh manager over the same tmux server, sidecars and fifos — what the +/// daemon builds after a restart. +fn revive(fx: &DaemonFixture) -> SessionManager { + SessionManager::new( + Arc::new(fx.tmux.tmux.clone()), + maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir()), + maestro_daemon::worktree::WorktreeManager::new(None), + fx.fifo_dir(), + ) +} + +/// Create a session, then simulate the host reboot that leaves it dormant: +/// the tmux server dies, the sidecar stays, a new manager recovers. +async fn dormant_session( + fx: &DaemonFixture, + cwd: &TempDir, + command: &str, + name: &str, +) -> (SessionManager, Session) { + let mut metadata = HashMap::new(); + metadata.insert( + maestro_protocol::NAME_KEY.to_string(), + serde_json::Value::String(name.into()), + ); + let session = fx + .manager + .create(CreateSessionRequest { + cwd: cwd.path().to_path_buf(), + command: command.into(), + env: HashMap::from([("MAESTRO_TEST_ENV".to_string(), "kept".to_string())]), + metadata, + worktree: None, + }) + .await + .expect("create"); + fx.tmux.tmux.kill_server().expect("kill tmux server"); + let revived = revive(fx); + revived.recover().await.expect("recover"); + (revived, session) +} + +fn marker_preset(command: &str, marker_dir: &TempDir) -> Vec { + vec![SpawnPreset { + id: "agent".into(), + label: "Agent".into(), + command: command.into(), + resume_command: Some(format!( + "sh -c 'touch {}/resumed-{{agent_session}}; sleep 30'", + marker_dir.path().display() + )), + }] +} + +/// The keystone: a dormant session comes back running under its own id, with +/// its name, `created_at`, env and sidecar untouched. +#[tokio::test] +async fn resume_brings_a_dormant_session_back_under_the_same_id() { + let fx = DaemonFixture::new(); + let cwd = TempDir::new().unwrap(); + let (manager, original) = dormant_session(&fx, &cwd, "sleep 30", "long-runner").await; + assert_eq!( + manager.status(original.id).await, + Some(SessionStatus::Dormant) + ); + + let resumed = manager.resume(original.id, &[]).await.expect("resume"); + + assert_eq!(resumed.id, original.id, "same uuid"); + assert_eq!(resumed.status, SessionStatus::Running); + assert_eq!(resumed.created_at, original.created_at); + assert_eq!(resumed.cwd, original.cwd); + assert_eq!(resumed.command, original.command); + assert_eq!(resumed.env, original.env); + assert_eq!( + resumed + .metadata + .get(maestro_protocol::NAME_KEY) + .and_then(|v| v.as_str()), + Some("long-runner") + ); + + // tmux really holds it again, and the sidecar is the same file, updated. + let live = fx.tmux.tmux.list_sessions().expect("list"); + assert_eq!(live, vec![original.id]); + let stored = maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir()) + .read(original.id) + .expect("read sidecar") + .expect("sidecar still there"); + assert_eq!(stored.status, SessionStatus::Running); + assert_eq!(stored.created_at, original.created_at); + + // And it is attachable again, which a dormant session never was. + assert!(manager + .subscribe_pty_with_replay(original.id) + .await + .is_some()); +} + +/// The worktree the session owns stays the session's, so killing the resumed +/// session still cleans the tree up. +#[tokio::test] +async fn resume_keeps_worktree_ownership() { + let fx = DaemonFixture::new(); + let repo = TempDir::new().unwrap(); + common::init_repo(repo.path()); + + let session = fx + .manager + .create(CreateSessionRequest { + cwd: repo.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(maestro_protocol::WorktreeSpec { + base_repo: repo.path().to_path_buf(), + branch: "feature".into(), + create_branch: true, + }), + }) + .await + .expect("create"); + let worktree = session.worktree.clone().expect("worktree"); + + fx.tmux.tmux.kill_server().expect("kill tmux server"); + let manager = revive(&fx); + manager.recover().await.expect("recover"); + let resumed = manager.resume(session.id, &[]).await.expect("resume"); + + assert_eq!(resumed.worktree, Some(worktree.clone())); + assert_eq!( + resumed.cwd, worktree.path, + "resumed inside its own worktree" + ); +} + +/// The command comes from the preset's `resume_command`, with the session's +/// own agent conversation id (#161) substituted in. +#[tokio::test] +async fn resume_runs_the_presets_resume_command() { + let fx = DaemonFixture::new(); + let cwd = TempDir::new().unwrap(); + let markers = TempDir::new().unwrap(); + let presets = marker_preset("sleep 30", &markers); + + let (manager, session) = dormant_session(&fx, &cwd, "sleep 30", "agentic").await; + // The agent reported its conversation id before the reboot, so it is on + // the sidecar and the resume template can be filled in. + fx.manager + .report_activity( + session.id, + maestro_protocol::ActivityState::Idle, + None, + Some("conv-42".into()), + ) + .await + .ok(); + // Re-read the sidecar the report updated. + let manager = { + drop(manager); + let m = revive(&fx); + m.recover().await.expect("recover"); + m + }; + + manager.resume(session.id, &presets).await.expect("resume"); + + let marker = markers.path().join("resumed-conv-42"); + wait_for_async(Duration::from_secs(5), || { + let marker = marker.clone(); + async move { marker.exists() } + }) + .await; +} + +/// A session that is already running has nothing to bring back, and +/// respawning would strand the pane that is already there. +#[tokio::test] +async fn resume_refuses_a_running_session() { + let fx = DaemonFixture::new(); + let cwd = TempDir::new().unwrap(); + let session = fx + .manager + .create(CreateSessionRequest { + cwd: cwd.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + }) + .await + .expect("create"); + + let err = fx + .manager + .resume(session.id, &[]) + .await + .expect_err("already running"); + assert!(matches!(err, SessionError::AlreadyRunning(id) if id == session.id)); + assert_eq!(err.to_error_info().code, "already_running"); +} + +/// Nothing can be resumed in a directory that no longer exists. +#[tokio::test] +async fn resume_refuses_when_the_cwd_is_gone() { + let fx = DaemonFixture::new(); + let cwd = TempDir::new().unwrap(); + let (manager, session) = dormant_session(&fx, &cwd, "sleep 30", "homeless").await; + + std::fs::remove_dir_all(cwd.path()).unwrap(); + + let err = manager + .resume(session.id, &[]) + .await + .expect_err("cwd is gone"); + assert_eq!(err.to_error_info().code, "cwd_missing"); + assert_eq!( + manager.status(session.id).await, + Some(SessionStatus::Dormant), + "a refused resume leaves the session exactly as it was" + ); +} + +#[tokio::test] +async fn resume_of_an_unknown_session_is_not_found() { + let fx = DaemonFixture::new(); + let err = fx + .manager + .resume(Uuid::new_v4(), &[]) + .await + .expect_err("unknown"); + assert_eq!(err.to_error_info().code, "not_found"); +} + +// --------------------------------------------------------------------------- +// HTTP surface +// --------------------------------------------------------------------------- + +struct Harness { + addr: SocketAddr, + _shutdown: oneshot::Sender<()>, +} + +async fn serve(manager: SessionManager, presets: Vec) -> Harness { + let router = local_api::router(local_api::state_for( + manager, + "test-host", + "/home/test", + presets, + Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), + maestro_config::DEFAULT_LEADER, + )); + let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) + .await + .unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, rx) = oneshot::channel::<()>(); + tokio::spawn(async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { + let _ = rx.await; + }) + .await; + }); + Harness { + addr, + _shutdown: tx, + } +} + +#[tokio::test] +async fn post_resume_returns_the_revived_session() { + let fx = DaemonFixture::new(); + let cwd = TempDir::new().unwrap(); + let (manager, session) = dormant_session(&fx, &cwd, "sleep 30", "web-resumed").await; + let h = serve(manager, Vec::new()).await; + + let resp = reqwest::Client::new() + .post(format!( + "http://{}{}", + h.addr, + maestro_protocol::routes::session_resume(session.id) + )) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::OK); + let body: Session = resp.json().await.unwrap(); + assert_eq!(body.id, session.id); + assert_eq!(body.status, SessionStatus::Running); +} + +#[tokio::test] +async fn post_resume_on_a_running_session_is_a_conflict() { + let fx = DaemonFixture::new(); + let cwd = TempDir::new().unwrap(); + let session = fx + .manager + .create(CreateSessionRequest { + cwd: cwd.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + }) + .await + .expect("create"); + let h = serve(fx.manager.clone(), Vec::new()).await; + + let resp = reqwest::Client::new() + .post(format!( + "http://{}{}", + h.addr, + maestro_protocol::routes::session_resume(session.id) + )) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::CONFLICT); + let err: maestro_protocol::ApiError = resp.json().await.unwrap(); + assert_eq!(err.error.code, "already_running"); +} + +#[tokio::test] +async fn post_resume_on_an_unknown_session_is_404() { + let fx = DaemonFixture::new(); + let h = serve(fx.manager.clone(), Vec::new()).await; + let resp = reqwest::Client::new() + .post(format!( + "http://{}{}", + h.addr, + maestro_protocol::routes::session_resume(Uuid::new_v4()) + )) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND); +} diff --git a/docs/RESTART.md b/docs/RESTART.md index 8296fd8..d9026d6 100644 --- a/docs/RESTART.md +++ b/docs/RESTART.md @@ -79,9 +79,26 @@ to adopt while every session's sidecar is still on disk. Recovery makes a second pass over those sidecars and registers each as a `Dormant` session (#162) — id, name, cwd, command, worktree and `created_at` intact. A dormant session has no pane and no PTY bridge: it cannot be attached to, typed into or -resized, and neither watcher pass touches it. It is a record of what was -running, and the only thing left to do with one is dismiss it, which removes -the sidecar so the next recovery does not bring it back. +resized, and neither watcher pass touches it. + +A dormant session is brought back with `POST /api/sessions/:id/resume` (#166), +which respawns tmux under the **same** uuid and sidecar — `metadata.name`, +`created_at`, `env` and worktree ownership all survive, and only the tmux +session and the PTY bridge are new. The command is the matching spawn preset's +`resume_command` with `{agent_session}` filled in from the session's own +`agent_session_id`, so an agent comes back inside its old conversation; a +session with no matching preset, no `resume_command`, or no reported +conversation id is respawned on the command it was created with. `Session::command` +always keeps that original command, so a second resume resolves the same preset. +Resume is refused with `409` for a session that is already running +(`already_running`) and for one whose `cwd` has disappeared (`cwd_missing`) — +neither is something a respawn could fix. The other thing to do with a dormant +session is dismiss it, which removes the sidecar so the next recovery does not +bring it back. + +Attaching to a session that has no bridge answers `409 not_attachable`, not +`404 unknown_session`: the session exists, and a client that cannot tell "gone" +from "not running" cannot offer the resume. `SessionStatus` is a wire type, so from this build forward it deserializes leniently on both clients: a spelling the client does not know reads as `Dead`