diff --git a/crates/maestro-daemon/src/dirlist.rs b/crates/maestro-daemon/src/dirlist.rs index cae1c96..19a14ec 100644 --- a/crates/maestro-daemon/src/dirlist.rs +++ b/crates/maestro-daemon/src/dirlist.rs @@ -111,6 +111,7 @@ fn io_error(dir: &Path, e: &std::io::Error) -> ErrorInfo { ErrorInfo { code: code.into(), message: format!("{message}: {}", dir.display()), + unsaved_work: None, } } diff --git a/crates/maestro-daemon/src/local_api.rs b/crates/maestro-daemon/src/local_api.rs index 69a4494..089400c 100644 --- a/crates/maestro-daemon/src/local_api.rs +++ b/crates/maestro-daemon/src/local_api.rs @@ -94,6 +94,8 @@ fn session_error_status(code: &str) -> StatusCode { match code { "not_found" => StatusCode::NOT_FOUND, "invalid_env" => StatusCode::BAD_REQUEST, + // A guard refusal is the daemon doing its job, not a failure (#79). + "kill_guarded" => StatusCode::CONFLICT, _ => StatusCode::BAD_GATEWAY, } } @@ -168,16 +170,17 @@ async fn create_session( /// `kill` already reports a missing session as `SessionError::NotFound`, which /// maps to 404. Checking first would only add a window in which the liveness /// watcher reaps the session between the two awaits, turning a 404 into a 502. +/// +/// The full error info is passed through rather than rebuilt from +/// code + message: a `kill_guarded` refusal (#79) carries the unsaved-work +/// breakdown clients render as "3 uncommitted files, 2 unpushed commits". async fn kill_session(State(s): State, Path(id): Path) -> Response { match s.manager.kill(id).await { Ok(()) => StatusCode::NO_CONTENT.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(), - ) + let status = session_error_status(&info.code); + (status, Json(ApiError { error: info })).into_response() } } } diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index 72ae8f9..04fd3aa 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -38,8 +38,8 @@ pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2); pub struct SessionRecord { pub session: Session, pub fifo_path: PathBuf, - /// Worktree we created at spawn time; removed on kill unless the - /// dirty-work guard refuses (#56). + /// Worktree we created at spawn time; removed on kill, but only after the + /// kill guard (#79) found nothing worth keeping in it. pub worktree_path: Option, /// `base_repo` from the original `WorktreeSpec`. Needed because `git /// worktree remove` is invoked from the base repo. @@ -61,6 +61,11 @@ pub enum SessionError { NotFound(Uuid), #[error("invalid environment variable name: {0:?}")] InvalidEnv(String), + /// 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. + #[error("kill refused: the session's worktree holds unsaved work ({0})")] + KillGuarded(maestro_protocol::UnsavedWork), } impl SessionError { @@ -72,10 +77,16 @@ impl SessionError { SessionError::Io(_) => "io_failed", SessionError::NotFound(_) => "not_found", SessionError::InvalidEnv(_) => "invalid_env", + SessionError::KillGuarded(_) => "kill_guarded", + }; + let unsaved_work = match self { + SessionError::KillGuarded(b) => Some(*b), + _ => None, }; ErrorInfo { code: code.into(), message: self.to_string(), + unsaved_work, } } } @@ -370,20 +381,48 @@ impl SessionManager { } } - /// Kill a session and remove its on-disk artifacts. The worktree is - /// always pruned -- unless it holds uncommitted changes or commits not on - /// any other ref, in which case the guard leaves it on disk (#56). + /// Kill a session and remove its on-disk artifacts. + /// + /// Refused (#79) when the session's worktree holds unsaved work — + /// uncommitted tracked changes, untracked non-ignored files, or commits + /// reachable from no remote-tracking ref. Nothing is killed and nothing + /// is removed; the error names what blocked it and how much of it there + /// is. The session stays as the operator's handle on their own unfinished + /// work, so ending a session that still matters becomes a deliberate + /// `--force`, which retains rather than destroys (WORKTREES.md). /// /// This is also how an already-dead session is dismissed. tmux is only /// asked to kill something it still has — calling `kill-session` on a /// session tmux already lost fails, and that failure is not interesting. pub async fn kill(&self, id: Uuid) -> Result<(), SessionError> { + let (worktree, alive) = { + let sessions = self.inner.sessions.lock().await; + match sessions.get(&id) { + None => return Err(SessionError::NotFound(id)), + Some(record) => ( + record + .worktree_base_repo + .clone() + .zip(record.worktree_path.clone()), + record.session.status == SessionStatus::Running, + ), + } + }; + // The guard runs before anything is torn down: refusing must leave the + // session running, exactly as the operator left it. + if let Some((_, path)) = worktree { + let probe = tmux_blocking(move || crate::worktree::unsaved_work(&path)) + .await + .map_err(SessionError::Worktree)?; + if !probe.is_empty() { + return Err(SessionError::KillGuarded(probe)); + } + } let removed = { let mut sessions = self.inner.sessions.lock().await; sessions.remove(&id) }; let record = removed.ok_or(SessionError::NotFound(id))?; - let alive = record.session.status == SessionStatus::Running; cleanup_record(&self.inner, id, record, alive).await; Ok(()) } @@ -720,10 +759,9 @@ async fn cleanup_record(inner: &Arc, id: Uuid, record: SessionRecord, kil if let Err(e) = inner.sidecar.delete(id) { tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar"); } - // Cleanup is unconditional now (#56): `cleanup_on_kill` is no longer a - // user-facing choice. The manager's guard is what protects unsaved work -- - // when it refuses, leave the tree on disk and say where it is so the - // operator can find it. + // The kill already passed the unsaved-work guard (#79), so this removal + // is unconditional; the manager's own nested-worktree refusal is all that + // can still leave a tree on disk, and it says where. if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) { if let Err(e) = inner.worktree_mgr.remove(base, path) { tracing::warn!( diff --git a/crates/maestro-daemon/tests/local_api.rs b/crates/maestro-daemon/tests/local_api.rs index b04abeb..8d9b2d4 100644 --- a/crates/maestro-daemon/tests/local_api.rs +++ b/crates/maestro-daemon/tests/local_api.rs @@ -9,7 +9,7 @@ use std::time::Duration; use common::DaemonFixture; use futures_util::{SinkExt, StreamExt}; use maestro_daemon::local_api; -use maestro_protocol::{CreateSessionRequest, Session}; +use maestro_protocol::{ApiError, CreateSessionRequest, Session, WorktreeSpec}; use tokio::net::TcpListener; use tokio::sync::oneshot; use tokio_tungstenite::tungstenite::Message as WsMessage; @@ -338,6 +338,53 @@ async fn delete_owned_session_kills_it() { assert!(!h.manager.has(session.id).await); } +/// The kill guard on the wire (#79): `DELETE /api/sessions/:id` answers 409 +/// with the structured breakdown, and the session keeps running. +#[tokio::test] +async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the_breakdown() { + let h = spawn("naps-laptop").await; + let repo = tempfile::TempDir::new().unwrap(); + common::init_repo(repo.path()); + let session = h + .manager + .create(CreateSessionRequest { + cwd: repo.path().to_path_buf(), + command: "sleep 60".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(WorktreeSpec { + base_repo: repo.path().to_path_buf(), + branch: "feat/guarded".into(), + cleanup_on_kill: true, + create_branch: true, + }), + }) + .await + .unwrap(); + std::fs::write( + repo.path() + .join("worktrees") + .join("feat") + .join("guarded") + .join("README"), + b"unsaved edits\n", + ) + .unwrap(); + + let resp = reqwest::Client::new() + .delete(url(h.addr, &format!("/api/sessions/{}", session.id))) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::CONFLICT); + let body: ApiError = resp.json().await.unwrap(); + assert_eq!(body.error.code, "kill_guarded"); + let breakdown = body.error.unsaved_work.expect("breakdown on the wire"); + assert_eq!(breakdown.dirty_tracked_files, 1); + + assert!(h.manager.has(session.id).await, "the session keeps running"); +} + #[tokio::test] async fn attach_ws_streams_pty_bytes() { let h = spawn("naps-laptop").await; diff --git a/crates/maestro-daemon/tests/session_manager.rs b/crates/maestro-daemon/tests/session_manager.rs index 15e9aa2..e9c5ad7 100644 --- a/crates/maestro-daemon/tests/session_manager.rs +++ b/crates/maestro-daemon/tests/session_manager.rs @@ -4,10 +4,12 @@ mod common; use std::collections::HashMap; use std::path::PathBuf; +use std::process::Command; use std::sync::Arc; use std::time::Duration; -use common::{init_repo, wait_for_async, DaemonFixture}; +use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture}; +use maestro_daemon::SessionError; use maestro_protocol::CreateSessionRequest; use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec}; use tempfile::TempDir; @@ -363,8 +365,9 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() { assert!(!fx.fifo_dir().join(format!("{}.fifo", s.id)).exists()); } -/// Cleanup is unconditional (#56): even a spec that says "don't clean up" -/// loses its worktree on kill — the guard, not the flag, protects work. +/// Cleanup on kill is unconditional once the guard passes (#79): even a spec +/// that says "don't clean up" loses its worktree — the branch was pushed, so +/// nothing in the tree is unique and removal goes ahead. #[tokio::test] async fn kill_removes_worktree_even_when_spec_says_keep() { let fx = DaemonFixture::new(); @@ -390,14 +393,21 @@ async fn kill_removes_worktree_even_when_spec_says_keep() { .expect("create"); assert!(wt_path.exists()); + // Push the branch so its commits are reachable from a remote-tracking ref. + let origin = TempDir::new().unwrap(); + init_bare_repo(origin.path()); + push_all_to_origin(repo.path(), origin.path()); + fx.manager.kill(s.id).await.expect("kill"); assert!(!wt_path.exists(), "worktree must be removed on kill"); } -/// The guard (#56): a worktree holding uncommitted changes survives the kill, -/// left on disk where the operator can find it. +/// The kill guard (#79): a worktree holding uncommitted changes refuses the +/// kill outright. Nothing happens — the session keeps running as the +/// operator's handle on their unfinished work, and the tree is untouched. +/// The refusal names what blocked it and how much of it there is. #[tokio::test] -async fn kill_leaves_worktree_with_unsaved_work_on_disk() { +async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() { let fx = DaemonFixture::new(); let repo = TempDir::new().unwrap(); init_repo(repo.path()); @@ -420,13 +430,138 @@ async fn kill_leaves_worktree_with_unsaved_work_on_disk() { .await .expect("create"); std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap(); + std::fs::write(wt_path.join("notes.txt"), b"untracked\n").unwrap(); - fx.manager.kill(s.id).await.expect("kill"); + let err = fx.manager.kill(s.id).await.expect_err("kill must refuse"); + let SessionError::KillGuarded(breakdown) = err else { + panic!("expected KillGuarded, got {err:?}"); + }; + assert_eq!(breakdown.dirty_tracked_files, 1); + assert_eq!(breakdown.untracked_files, 1); assert!( - wt_path.exists(), - "dirty worktree must be left on disk, not destroyed" + breakdown.unpushed_commits >= 1, + "a fresh branch has no remote-tracking ref" + ); + let message = breakdown.to_string(); + assert!(message.contains("1 uncommitted file"), "{message}"); + assert!(message.contains("1 untracked file"), "{message}"); + + // Nothing happened: the session survives and so does the tree. + assert!(fx.manager.has(s.id).await, "the session must keep running"); + assert!(wt_path.exists(), "the worktree must be untouched"); + + // A second kill after the work is saved goes through. + run_git_in(&wt_path, &["add", "."]); + run_git_in(&wt_path, &["commit", "-q", "-m", "saved"]); + let origin = TempDir::new().unwrap(); + init_bare_repo(origin.path()); + push_all_to_origin(repo.path(), origin.path()); + fx.manager.kill(s.id).await.expect("kill after saving"); + assert!(!fx.manager.has(s.id).await); +} + +/// Unpushed commits alone block the kill (#79): a feature branch almost +/// always has them, which is why `--force` (which retains) is the ordinary +/// way to end such a session. +#[tokio::test] +async fn killing_refuses_until_the_branch_is_pushed() { + let fx = DaemonFixture::new(); + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + let wt_path = repo.path().join("worktrees").join("feat").join("pushed"); + + let s = fx + .manager + .create(CreateSessionRequest { + cwd: repo.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(WorktreeSpec { + base_repo: repo.path().to_path_buf(), + branch: "feat/pushed".into(), + cleanup_on_kill: true, + create_branch: true, + }), + }) + .await + .expect("create"); + + let err = fx.manager.kill(s.id).await.expect_err("unpushed blocks"); + let SessionError::KillGuarded(breakdown) = err else { + panic!("expected KillGuarded, got {err:?}"); + }; + assert_eq!(breakdown.dirty_tracked_files, 0); + assert_eq!(breakdown.untracked_files, 0); + assert!(breakdown.unpushed_commits >= 1); + + let origin = TempDir::new().unwrap(); + init_bare_repo(origin.path()); + push_all_to_origin(repo.path(), origin.path()); + + fx.manager.kill(s.id).await.expect("pushed branch kills"); + assert!(!wt_path.exists()); + assert!(!fx.manager.has(s.id).await); +} + +/// Ignored files NEVER block (#79): build output like `target/` exists in +/// every Rust checkout, and an ignored-only worktree stays killable. +#[tokio::test] +async fn killing_an_ignored_only_worktree_succeeds() { + let fx = DaemonFixture::new(); + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + // .gitignore must be committed before the worktree branches off, or it + // would itself read as an untracked file. + std::fs::write(repo.path().join(".gitignore"), b"/target\n").unwrap(); + run_git_in(repo.path(), &["add", ".gitignore"]); + run_git_in(repo.path(), &["commit", "-q", "-m", "ignore target"]); + let origin = TempDir::new().unwrap(); + init_bare_repo(origin.path()); + + let wt_path = repo.path().join("worktrees").join("feat").join("rusty"); + let s = fx + .manager + .create(CreateSessionRequest { + cwd: repo.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(WorktreeSpec { + base_repo: repo.path().to_path_buf(), + branch: "feat/rusty".into(), + cleanup_on_kill: true, + create_branch: true, + }), + }) + .await + .expect("create"); + std::fs::create_dir_all(wt_path.join("target")).unwrap(); + std::fs::write(wt_path.join("target").join("app.o"), b"binary\n").unwrap(); + + // Branch pushed; the only dirt is ignored output. + push_all_to_origin(repo.path(), origin.path()); + + fx.manager + .kill(s.id) + .await + .expect("ignored files never block"); + assert!(!wt_path.exists()); + assert!(!fx.manager.has(s.id).await); +} + +fn run_git_in(dir: &std::path::Path, args: &[&str]) { + let out = Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .expect("git spawn"); + assert!( + out.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr) ); - assert!(fx.manager.snapshot().await.is_empty()); } #[tokio::test] diff --git a/crates/maestro-e2e/tests/e2e.rs b/crates/maestro-e2e/tests/e2e.rs index cbb213a..b049014 100644 --- a/crates/maestro-e2e/tests/e2e.rs +++ b/crates/maestro-e2e/tests/e2e.rs @@ -160,6 +160,7 @@ async fn kill_removes_clean_worktree() { "expected a worktree for feature/e2e under /worktrees, got: {worktrees:?}" ); + push_to_throwaway_origin(base.path()); client.kill_session(s.id).await.expect("kill"); // Give the daemon a moment to remove the worktree, then verify. @@ -177,11 +178,12 @@ async fn kill_removes_clean_worktree() { ); } -/// The guard (#56): killing a session whose worktree holds uncommitted -/// changes must leave the tree on disk — cleanup is unconditional, so this -/// guard is what keeps a stray kill from destroying work. +/// The kill guard (#79): killing a session whose worktree holds uncommitted +/// changes is refused outright. Nothing happens — the session keeps running +/// as the operator's handle on their unfinished work, the tree stays on disk, +/// and the refusal names what blocked it and how much. #[tokio::test(flavor = "multi_thread")] -async fn kill_spares_worktree_with_uncommitted_changes() { +async fn kill_refuses_worktree_with_uncommitted_changes() { let (_daemon, client) = bring_up().await; let base = TempDir::new().unwrap(); @@ -209,17 +211,25 @@ async fn kill_spares_worktree_with_uncommitted_changes() { assert!(wt_path.exists()); std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap(); - client.kill_session(s.id).await.expect("kill"); - - // The session is gone but the dirty worktree stays findable on disk. - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline { - let sessions = client.list_sessions().await.unwrap_or_default(); - if sessions.is_empty() { - break; + let err = client + .kill_session(s.id) + .await + .expect_err("kill must refuse"); + match err { + maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. }) => { + assert_eq!(info.code, "kill_guarded", "{info:?}"); + let breakdown = info.unsaved_work.expect("breakdown on the wire"); + assert_eq!(breakdown.dirty_tracked_files, 1); } - tokio::time::sleep(Duration::from_millis(100)).await; + other => panic!("expected a kill_guarded refusal, got {other:?}"), } + + // The session survives and the dirty worktree stays findable on disk. + let sessions = client.list_sessions().await.unwrap_or_default(); + assert!( + sessions.iter().any(|x| x.id == s.id), + "a refused kill must keep the session alive" + ); assert!( wt_path.exists(), "guarded worktree must be left on disk after kill" @@ -270,6 +280,7 @@ async fn spawn_on_existing_branch_checks_it_out() { "expected a worktree checked out at feature/existing, got: {worktrees:?}" ); + push_to_throwaway_origin(base.path()); client.kill_session(s.id).await.expect("kill"); } @@ -317,6 +328,7 @@ async fn spawn_new_branch_creates_worktree_and_runs_in_it() { ) .await; + push_to_throwaway_origin(base.path()); client.kill_session(s.id).await.expect("kill"); } @@ -364,6 +376,7 @@ async fn second_spawn_on_same_branch_is_rejected() { "original worktree must be untouched" ); + push_to_throwaway_origin(base.path()); client.kill_session(first.id).await.expect("kill"); } @@ -406,6 +419,7 @@ async fn spawn_from_linked_worktree_places_in_main_checkout() { "worktrees must never nest inside another worktree" ); + push_to_throwaway_origin(base.path()); client.kill_session(s.id).await.expect("kill"); } @@ -774,6 +788,24 @@ fn git_worktree_list(dir: &std::path::Path) -> Vec { .collect() } +/// Push every branch of `repo` to a throwaway bare remote, so its commits are +/// reachable from remote-tracking refs. Without one the kill guard (#79) +/// reads every commit as unpushed, and any worktree session refuses to die. +fn push_to_throwaway_origin(repo: &std::path::Path) { + let origin = tempfile::TempDir::new().unwrap(); + run_git( + &["init", "-q", "--bare", origin.path().to_str().unwrap()], + repo, + ); + let _ = Command::new("git") + .arg("-C") + .arg(repo) + .args(["remote", "add", "origin", origin.path().to_str().unwrap()]) + .output() + .expect("git remote add"); + run_git(&["push", "-q", "--all", "origin"], repo); +} + #[tokio::test(flavor = "multi_thread")] async fn daemon_events_ws_streams_snapshot_then_session_added() { // NAP-248: subscribe to the daemon's WS /api/events, expect an initial