From c004b5d1b694a3fd93201858f4077b9c4906f1eb Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Fri, 4 Sep 2026 08:30:39 +0100 Subject: [PATCH] feat(maestro): prune sidecars whose cwd no longer exists recover()'s dormant-registration pass (#162) now deletes a sidecar instead of registering it dormant when the directory it points at is gone. Nothing can be resumed there, so the honest move is to remove the record rather than keep an unactionable one around (#163). Sidecars whose cwd still exists are unaffected, regardless of age. --- crates/maestro-daemon/src/session.rs | 16 +++++ .../maestro-daemon/tests/session_manager.rs | 63 +++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index cff9aad..2cf7a0b 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -756,6 +756,11 @@ impl SessionManager { /// /// Activity is not carried over: whatever the session was doing, it is not /// doing it now. The rest of the sidecar is left as written. + /// + /// #163: a sidecar whose `cwd` no longer exists is pruned instead — + /// nothing can be resumed in a directory that is gone, so the honest move + /// is to delete the sidecar rather than register a record no operator can + /// act on. A sidecar whose `cwd` still exists is kept regardless of age. async fn register_dormant(&self) { let stored = match self.inner.sidecar.load_all() { Ok(stored) => stored, @@ -765,6 +770,7 @@ impl SessionManager { } }; let mut registered = Vec::new(); + let mut pruned = 0usize; { let mut sessions = self.inner.sessions.lock().await; for mut session in stored { @@ -772,6 +778,13 @@ impl SessionManager { // Adopted live by the first pass; its sidecar is on disk too. continue; } + if !session.cwd.exists() { + if let Err(e) = self.inner.sidecar.delete(session.id) { + tracing::warn!(error = ?e, id = %session.id, "recover: failed to prune sidecar with missing cwd"); + } + pruned += 1; + continue; + } let id = session.id; session.status = SessionStatus::Dormant; session.foreground = None; @@ -805,6 +818,9 @@ impl SessionManager { "registered sessions with no live tmux as dormant" ); } + if pruned > 0 { + tracing::info!(pruned, "pruned sidecars whose cwd no longer exists"); + } for session in registered { let _ = self .inner diff --git a/crates/maestro-daemon/tests/session_manager.rs b/crates/maestro-daemon/tests/session_manager.rs index 441bd8b..e5d8c58 100644 --- a/crates/maestro-daemon/tests/session_manager.rs +++ b/crates/maestro-daemon/tests/session_manager.rs @@ -234,6 +234,69 @@ async fn recover_does_not_mark_adopted_sessions_dormant() { assert_eq!(snap[1].status, SessionStatus::Dormant); } +/// #163: a sidecar whose `cwd` no longer exists is pruned instead of +/// registered dormant — the worktree it pointed at is gone, so there is +/// nothing left to resume. +#[tokio::test] +async fn recover_deletes_sidecar_when_cwd_gone() { + let fx = DaemonFixture::new(); + let cwd = TempDir::new().unwrap(); + let cwd_path = cwd.path().to_path_buf(); + + let s = fx + .manager + .create(CreateSessionRequest { + cwd: cwd_path.clone(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + }) + .await + .expect("create"); + + fx.tmux.tmux.kill_server().expect("kill tmux server"); + drop(cwd); + std::fs::remove_dir_all(&cwd_path).ok(); + assert!(!cwd_path.exists()); + + let revived = revive(&fx); + let adopted = revived.recover().await.expect("recover"); + assert_eq!(adopted, 0); + + assert!(!revived.has(s.id).await); + assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists()); +} + +/// #163: a sidecar whose `cwd` still exists is left alone regardless of age +/// — it is registered dormant like any other orphan. +#[tokio::test] +async fn recover_keeps_dormant_sidecar_when_cwd_exists() { + let fx = DaemonFixture::new(); + let cwd = TempDir::new().unwrap(); + + let s = 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"); + fx.tmux.tmux.kill_server().expect("kill tmux server"); + + let revived = revive(&fx); + revived.recover().await.expect("recover"); + + assert!(revived.has(s.id).await); + assert!(fx.sidecar_dir().join(format!("{}.json", s.id)).exists()); + let snap = revived.snapshot().await; + assert_eq!(snap[0].status, SessionStatus::Dormant); +} + /// #162: dismissing a dormant session is the one thing that can be done to it /// — no tmux call to fail, and the sidecar goes with it so the next recovery /// does not resurrect it.