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.