diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index 9ea3942..7861155 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -62,6 +62,11 @@ pub struct SessionRecord { /// resumes and [`Session::activity_detail`] is cleared with it. This /// field is daemon-local bookkeeping only — it never rides the wire. pub reported_activity: Option, + /// Flipped once the natural-exit settle step (#82) has been applied or + /// refused for this session. `Dead` is observable before settling ends + /// (#115), so this is the observable that says its outcomes are safe to + /// assert on. Daemon-local bookkeeping; never rides the wire. + pub settled: bool, } #[derive(Debug, thiserror::Error)] @@ -294,6 +299,17 @@ impl SessionManager { sessions.get(&id).map(|r| r.session.status) } + /// Whether the natural-exit settle step (#82) has finished for this + /// session: `Some(true)` once applied or refused, `Some(false)` while + /// still pending, `None` if the session is unknown. `Dead` is observable + /// before settling ends (#115), so anything asserting on a dead + /// session's worktree waits on this rather than inferring settle + /// completion from its side effects. + pub async fn is_settled(&self, id: Uuid) -> Option { + let sessions = self.inner.sessions.lock().await; + sessions.get(&id).map(|r| r.settled) + } + /// Spawn a new session. On success returns the freshly-created `Session`. pub async fn create(&self, req: CreateSessionRequest) -> Result { let (effective_cwd, worktree_path, worktree_base_repo) = match &req.worktree { @@ -429,6 +445,7 @@ impl SessionManager { worktree_branch: req.worktree.as_ref().map(|w| w.branch.clone()), bridge, reported_activity: None, + settled: false, }, ); } @@ -657,6 +674,7 @@ impl SessionManager { worktree_branch: None, bridge, reported_activity: None, + settled: false, }, ); let _ = self @@ -1069,6 +1087,14 @@ async fn mark_session_dead(inner: &Arc, id: Uuid) { } } } + // Settle has now been applied or refused either way (#115): from here on + // its outcomes are safe to observe. + { + let mut sessions = inner.sessions.lock().await; + if let Some(record) = sessions.get_mut(&id) { + record.settled = true; + } + } let _ = inner.sidecar.write(&session); let _ = inner.events.send(CatalogEvent::SessionUpdated { session }); diff --git a/crates/maestro-daemon/tests/session_manager.rs b/crates/maestro-daemon/tests/session_manager.rs index 1ecc734..9bcc963 100644 --- a/crates/maestro-daemon/tests/session_manager.rs +++ b/crates/maestro-daemon/tests/session_manager.rs @@ -946,6 +946,14 @@ async fn natural_exit_without_a_retained_store_destroys_nothing() { }) .await; + // The settle step has been applied or refused either way — here refused, + // since there is no store to record the tree into. Only from this point + // is "the tree survived" a settled fact rather than a race (#115). + wait_for_async(Duration::from_secs(3), || async { + manager.is_settled(s.id).await == Some(true) + }) + .await; + assert!( wt_path.exists(), "nothing may be destroyed when it cannot be recorded"