From b05b014e84a045014cf8100ced094e5359a8beb9 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Fri, 4 Sep 2026 08:48:18 +0100 Subject: [PATCH] fix(maestro): clear worktree from the session clone on natural exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mark_session_dead cleared blocked_work on the local session clone written to the sidecar, but not worktree — so a pristine tree the daemon just removed stayed named in the sidecar, and recovery from that sidecar advertised a branch and path that no longer exist. Refs #172. Co-Authored-By: Claude Sonnet 5 --- crates/maestro-daemon/src/session.rs | 1 + .../maestro-daemon/tests/session_manager.rs | 60 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index cc8492b..c39af0f 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -1540,6 +1540,7 @@ async fn mark_session_dead(inner: &Arc, id: Uuid) { // The tree is settled (removed); a stale "in the way" would // describe something the session no longer holds. session.blocked_work = None; + session.worktree = None; let mut sessions = inner.sessions.lock().await; if let Some(record) = sessions.get_mut(&id) { record.session.worktree = None; diff --git a/crates/maestro-daemon/tests/session_manager.rs b/crates/maestro-daemon/tests/session_manager.rs index 08db711..4cb39a4 100644 --- a/crates/maestro-daemon/tests/session_manager.rs +++ b/crates/maestro-daemon/tests/session_manager.rs @@ -9,6 +9,7 @@ use std::sync::Arc; use std::time::Duration; use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture}; +use maestro_daemon::sidecar::SidecarStore; use maestro_daemon::SessionError; use maestro_protocol::CreateSessionRequest; use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec}; @@ -1304,6 +1305,65 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() { ); } +/// #172: `mark_session_dead` clears the worktree off the in-memory record +/// when a pristine tree settles, but the local `session` clone it writes to +/// the sidecar and broadcasts must be cleared the same way — otherwise the +/// sidecar keeps naming a tree the daemon just removed, and a recovery from +/// that sidecar advertises a branch and path that no longer exist. +#[tokio::test] +async fn natural_exit_of_a_pristine_worktree_clears_it_from_the_sidecar() { + let fx = DaemonFixture::new(); + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + let wt_path = repo.path().join("worktrees").join("feat").join("sidecar"); + + 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/sidecar".into(), + create_branch: true, + }), + }) + .await + .expect("create"); + + // Push everything so the branch has no unpushed commits: fully pristine. + let origin = TempDir::new().unwrap(); + init_bare_repo(origin.path()); + push_all_to_origin(repo.path(), origin.path()); + + fx.tmux.tmux.kill_session(s.id).expect("kill tmux directly"); + fx.manager.start_liveness_watcher(Duration::from_millis(50)); + wait_for_async(Duration::from_secs(3), || async { + fx.manager.status(s.id).await == Some(SessionStatus::Dead) + }) + .await; + wait_for_async(Duration::from_secs(3), || async { + fx.manager.is_settled(s.id).await == Some(true) + }) + .await; + + assert!( + !wt_path.exists(), + "a pristine worktree leaves nothing behind" + ); + + let sidecar = SidecarStore::new(fx.sidecar_dir()) + .read(s.id) + .expect("sidecar read") + .expect("sidecar exists"); + assert!( + sidecar.worktree.is_none(), + "the sidecar must not keep naming a worktree the daemon already removed" + ); +} + fn run_git_in(dir: &std::path::Path, args: &[&str]) { let out = Command::new("git") .arg("-C")