//! Session manager — high-level orchestration of tmux sessions, sidecars, //! worktrees, and PTY bridges. //! //! Owns the daemon's authoritative in-memory map of live sessions and is the //! single point through which the API handlers and the per-session WS //! handler interact with tmux. use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use chrono::Utc; use maestro_protocol::{ ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, KillOutcome, RetainedReason, RetainedWorktree, Session, SessionStatus, WorktreeSpec, }; use tokio::sync::{broadcast, Mutex}; use uuid::Uuid; use crate::panes::Panes; use crate::pty::{pipe_pane_command, PtyBridge}; use crate::sidecar::{SidecarError, SidecarStore}; use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord}; use crate::tmux::TmuxError; use crate::worktree::{WorktreeError, WorktreeManager}; /// Capacity of the session-events broadcast channel. A subscriber that falls /// this far behind gets `RecvError::Lagged`, which `/api/events` forwards as /// [`maestro_protocol::CatalogEvent::Lagged`] so that client re-fetches. pub const EVENTS_CAPACITY: usize = maestro_config::EVENTS_CAPACITY; /// How often the liveness watcher polls tmux for sessions that disappeared. pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2); /// How often the liveness watcher re-probes worktrees for the blocked-work /// indicator (#83). Each probe costs three git subprocesses per session, so /// the pass rides the same tick as everything else but runs on a slower /// cadence: an indicator a few seconds stale still warns before the kill /// attempt, which is all #83 needs. pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15); /// Per-session daemon-local bookkeeping that complements the public `Session`. #[derive(Debug)] pub struct SessionRecord { pub session: Session, pub fifo_path: PathBuf, /// 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. pub worktree_base_repo: Option, /// Branch the worktree was created for, recorded at spawn time so a /// forced kill (#81) can name it in the retained store without asking /// git. Sessions adopted from a sidecar have no branch on record and /// fall back to a probe. pub worktree_branch: Option, pub bridge: PtyBridge, } #[derive(Debug, thiserror::Error)] pub enum SessionError { #[error("tmux: {0}")] Tmux(#[from] TmuxError), #[error("worktree: {0}")] Worktree(#[from] WorktreeError), #[error("sidecar: {0}")] Sidecar(#[from] SidecarError), #[error("io: {0}")] Io(#[from] std::io::Error), #[error("session not found: {0}")] 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::InTheWay), /// A forced kill (#81) records the retained worktree *before* tearing /// anything down; if that write fails nothing was destroyed, and the /// error says so rather than silently dropping unrecorded work. #[error("could not record the retained worktree: {0}")] RetainFailed(String), } impl SessionError { pub fn to_error_info(&self) -> ErrorInfo { let code = match self { SessionError::Tmux(_) => "tmux_failed", SessionError::Worktree(_) => "worktree_failed", SessionError::Sidecar(_) => "sidecar_failed", SessionError::Io(_) => "io_failed", SessionError::NotFound(_) => "not_found", SessionError::InvalidEnv(_) => "invalid_env", SessionError::KillGuarded(_) => "kill_guarded", SessionError::RetainFailed(_) => "retain_failed", }; let unsaved_work = match self { SessionError::KillGuarded(b) => Some(*b), _ => None, }; ErrorInfo { code: code.into(), message: self.to_string(), unsaved_work, } } } /// Cheap-to-clone handle. #[derive(Debug, Clone)] pub struct SessionManager { inner: Arc, } #[derive(Debug)] struct Inner { tmux: Arc, sidecar: SidecarStore, worktree_mgr: WorktreeManager, fifo_dir: PathBuf, /// NAP-229: per-session PTY replay buffer cap in bytes. Each new attach /// receives the last `replay_bytes` of output before live frames. replay_bytes: usize, /// Durable spawn-history log backing the "recent dirs" picker (#24). /// `None` when not wired (tests, or before `with_spawn_history`); a present /// but disabled (`0`-day) history no-ops internally. spawn_history: Option, /// Retained-worktree store (#78). `None` when not wired (tests); a forced /// kill (#81) without one fails rather than destroying unrecorded work. retained: Option, /// The daemon's own HTTP base URL (#103), exported to spawned sessions as /// `MAESTRO_URL`. `None` when unknown — those sessions simply don't get /// the variable. A mutex because the runner sets it after construction, /// possibly while clones are already serving. daemon_url: std::sync::Mutex>, sessions: Mutex>, events: broadcast::Sender, } impl SessionManager { pub fn new( tmux: Arc, sidecar: SidecarStore, worktree_mgr: WorktreeManager, fifo_dir: PathBuf, ) -> Self { Self::new_with_replay( tmux, sidecar, worktree_mgr, fifo_dir, crate::pty::DEFAULT_REPLAY_BYTES, ) } /// Variant that lets the caller override the per-session replay buffer /// cap. Pass `0` to disable replay (subscribers see only live frames). pub fn new_with_replay( tmux: Arc, sidecar: SidecarStore, worktree_mgr: WorktreeManager, fifo_dir: PathBuf, replay_bytes: usize, ) -> Self { let (events, _) = broadcast::channel(EVENTS_CAPACITY); Self { inner: Arc::new(Inner { tmux, sidecar, worktree_mgr, fifo_dir, replay_bytes, spawn_history: None, retained: None, daemon_url: std::sync::Mutex::new(None), sessions: Mutex::new(HashMap::new()), events, }), } } /// Attach a durable spawn-history log (#24). Builder, called once right /// after construction while the `Arc` is uniquely owned. /// /// # Panics /// /// If the handle has already been cloned. That is a construction-order /// mistake, and the alternative — logging and carrying on — produces a /// daemon whose "recent dirs" picker is permanently empty with nothing to /// show for it. pub fn with_spawn_history(mut self, history: SpawnHistory) -> Self { let inner = Arc::get_mut(&mut self.inner) .expect("with_spawn_history must be called before the SessionManager is cloned"); inner.spawn_history = Some(history); self } /// Attach the retained-worktree store (#78). A forced kill (#81) needs it /// to record what it retained; without one, forcing a guarded kill fails /// instead of destroying unrecorded work. pub fn with_retained_store(mut self, store: crate::retained_store::RetainedStore) -> Self { let inner = Arc::get_mut(&mut self.inner) .expect("with_retained_store must be called before the SessionManager is cloned"); inner.retained = Some(store); self } /// Set the daemon's own HTTP base URL (#103), exported to every session /// spawned afterwards as `MAESTRO_URL`. Unlike the `with_*` builders this /// is a plain setter: the daemon runner learns its bound address after /// construction, and clones may already exist by then. pub fn set_daemon_url(&self, url: String) { *self.inner.daemon_url.lock().expect("daemon_url mutex") = Some(url); } /// Recent working dirs for the spawn form's picker (#24), newest-first and /// deduped by dir. Empty when no history is wired or it's disabled. pub fn recent_dirs(&self) -> Vec { self.inner .spawn_history .as_ref() .map(|h| h.recent_dirs()) .unwrap_or_default() } /// Every deduped recent dir, newest-first, without the picker's result /// cap (#54): project enrichment folds the whole history. pub fn all_recent_dirs(&self) -> Vec { self.inner .spawn_history .as_ref() .map(|h| h.all_recent_dirs()) .unwrap_or_default() } /// Subscribe to session deltas. Each subscriber gets its own lag domain — /// a slow client makes only itself resync. pub fn events(&self) -> broadcast::Receiver { self.inner.events.subscribe() } /// Snapshot of every live session. Suitable for `SessionsSnapshot`. pub async fn snapshot(&self) -> Vec { let sessions = self.inner.sessions.lock().await; sessions.values().map(|r| r.session.clone()).collect() } /// Subscribe to a session's PTY broadcast. `None` if the session is /// unknown to this daemon. pub async fn subscribe_pty(&self, id: Uuid) -> Option>> { let sessions = self.inner.sessions.lock().await; sessions.get(&id).map(|r| r.bridge.subscribe()) } /// Subscribe to a session's PTY broadcast and return the replay buffer /// (NAP-229). Callers send the buffer before pumping the live receiver /// so freshly-attached clients see the last screenful immediately. pub async fn subscribe_pty_with_replay( &self, id: Uuid, ) -> Option<(Vec, broadcast::Receiver>)> { let sessions = self.inner.sessions.lock().await; sessions.get(&id).map(|r| r.bridge.subscribe_with_replay()) } pub async fn has(&self, id: Uuid) -> bool { self.inner.sessions.lock().await.contains_key(&id) } /// Current status of a session, or `None` if we do not know it. pub async fn status(&self, id: Uuid) -> Option { let sessions = self.inner.sessions.lock().await; sessions.get(&id).map(|r| r.session.status) } /// 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 { Some(spec) => { let WorktreeSpec { base_repo, branch, .. } = spec; // A path collision is sidestepped inside `run_add` (#77), and // a branch already checked out elsewhere is git's refusal, // not ours. let path = if spec.create_branch { self.inner.worktree_mgr.add_new(base_repo, branch) } else { self.inner.worktree_mgr.add_existing(base_repo, branch) } .map_err(SessionError::Worktree)?; // Record the repository, not whatever the request named: a // spawn from inside a linked worktree passes that worktree as // `base_repo`, and clients group sessions by this path (#99). let repo = crate::worktree::main_checkout_of(base_repo); (path.clone(), Some(path), Some(repo)) } None => (req.cwd.clone(), None, None), }; let id = Uuid::new_v4(); // Build the FIFO + reader task *before* asking tmux to spawn the // command — otherwise the child's first bytes race the separate // pipe-pane call and get lost (NAP-253). `new_session_piped` chains // `pipe-pane` into the same tmux invocation as `new-session`, so the // pipe is attached before the child finishes `execve`. let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo")); // Each side effect below unwinds the ones before it on failure, so a // partial create never leaks a worktree or — worse — a ghost tmux // session with no in-memory record (NAP-260). let bridge = match PtyBridge::start_with_replay(fifo_path.clone(), self.inner.replay_bytes).await { Ok(b) => b, Err(e) => { self.rollback_worktree(&worktree_base_repo, &worktree_path); return Err(e.into()); } }; let mut session_env = match sanitize_env(&req.env) { Ok(env) => env, Err(e) => { let _ = std::fs::remove_file(&fifo_path); self.rollback_worktree(&worktree_base_repo, &worktree_path); return Err(e); } }; // Identity + reporting target for the in-session hook (#106). Ours // win over whatever the request carried, so a client cannot spoof // another session's identity (#103). session_env.insert("MAESTRO_SESSION_ID".into(), id.to_string()); if let Some(url) = self .inner .daemon_url .lock() .expect("daemon_url mutex") .clone() { session_env.insert("MAESTRO_URL".into(), url); } let spawned = { let tmux = self.inner.tmux.clone(); let cwd = effective_cwd.clone(); let command = req.command.clone(); let sink = pipe_pane_command(&fifo_path); let env = session_env.clone(); tmux_blocking(move || tmux.new_session_piped(id, &cwd, &command, &env, &sink)).await }; if let Err(e) = spawned { // `bridge` drops at the end of this block, closing the FIFO reader. let _ = std::fs::remove_file(&fifo_path); self.rollback_worktree(&worktree_base_repo, &worktree_path); return Err(SessionError::Tmux(e)); } // Capture spawn-history inputs before `req` is partially moved into // `session`. `history_cwd` is the *chosen* working dir (the request // `cwd`), not a generated worktree checkout path — that's what the // recents picker offers to reuse. let history_cwd = req.cwd.clone(); let worktree_branch = req.worktree.as_ref().map(|w| w.branch.clone()); let session = Session { id, cwd: effective_cwd, command: req.command, env: req.env, metadata: req.metadata, worktree: worktree_path.as_ref().zip(worktree_base_repo.as_ref()).map( |(path, base_repo)| maestro_protocol::SessionWorktree { path: path.clone(), base_repo: base_repo.clone(), }, ), status: SessionStatus::Running, created_at: Utc::now(), // A freshly-spawned session is producing its initial output; the // activity watcher refines `foreground`/`activity` on its next tick. foreground: None, activity: ActivityState::Active, last_activity_at: Utc::now(), activity_since: chrono::Utc::now(), current_cwd: None, // The watcher fills this in on its first blocked-work pass; a // fresh tree has nothing in the way anyway. blocked_work: None, }; if let Err(e) = self.inner.sidecar.write(&session) { // tmux already spawned the session; kill it so no ghost survives a // failed sidecar write, then unwind the FIFO + worktree. let tmux = self.inner.tmux.clone(); let _ = tmux_blocking(move || tmux.kill_session(id)).await; let _ = std::fs::remove_file(&fifo_path); self.rollback_worktree(&worktree_base_repo, &worktree_path); return Err(SessionError::Sidecar(e)); } { let mut sessions = self.inner.sessions.lock().await; sessions.insert( id, SessionRecord { session: session.clone(), fifo_path, worktree_path, worktree_base_repo, worktree_branch: req.worktree.as_ref().map(|w| w.branch.clone()), bridge, }, ); } let _ = self.inner.events.send(CatalogEvent::SessionAdded { session: session.clone(), }); // Best-effort: log the spawn for the recents picker (#24). A log-write // failure must never fail an otherwise-successful spawn, so the error // is only warned. No-op when history is unwired or disabled. if let Some(history) = &self.inner.spawn_history { let record = SpawnHistoryRecord { cwd: history_cwd, command: session.command.clone(), name: session .metadata .get("name") .and_then(|v| v.as_str()) .map(str::to_string), worktree_branch, at: session.created_at, }; if let Err(e) = history.append(&record) { tracing::warn!(error = %e, "spawn-history append failed (non-fatal)"); } } Ok(session) } /// Best-effort removal of a worktree created earlier in `create` when a /// later step fails. No-op when the request had no worktree. fn rollback_worktree(&self, base: &Option, path: &Option) { if let (Some(base), Some(path)) = (base, path) { if let Err(e) = self.inner.worktree_mgr.remove(base, path) { tracing::warn!(error = %e, ?path, "rollback: failed to remove worktree after partial create"); } } } /// 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). /// /// With `force` (#81) the refusal — and nothing else — is overridden: /// the session ends and the guarded worktree is retained into the store /// with reason `force-killed`, never removed. The outcome names what was /// kept so a client can say where the work went. A tree whose guard is /// clear never needed the override; force removes it as usual. /// /// 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, force: bool) -> Result { let (worktree, alive, known_branch) = { 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, record.worktree_branch.clone(), ), } }; let probe = match &worktree { Some(wt) => Some(probe_worktree(wt).await?), None => None, }; let guarded = probe.as_ref().is_some_and(|p| !p.is_clear()); if guarded && !force { return Err(SessionError::KillGuarded(probe.expect("guarded ⇒ probed"))); } let mut retained = None; if guarded { let entry = record_retention( &self.inner, worktree.as_ref().expect("guarded ⇒ worktree"), known_branch, RetainedReason::ForceKilled, ) .await?; retained = Some((entry, probe.expect("guarded ⇒ probed"))); } let removed = { let mut sessions = self.inner.sessions.lock().await; sessions.remove(&id) }; let record = removed.ok_or(SessionError::NotFound(id))?; cleanup_record(&self.inner, id, record, alive, retained.is_some()).await; Ok(KillOutcome { retained: retained.map(|(entry, in_the_way)| RetainedWorktree { id: entry.id, path: entry.path, base_repo: entry.base_repo, branch: entry.branch, reason: entry.reason, retained_at: entry.retained_at, in_the_way, }), }) } /// Spawn a background task that polls tmux every `interval` and reaps any /// in-memory sessions whose underlying tmux session has disappeared (the /// command exited, the user ran `tmux kill-session`, etc.). /// /// The task holds a `Weak` and exits cleanly once the last /// `SessionManager` clone is dropped — call this once from the daemon /// startup path after [`Self::recover`] has populated the map. pub fn start_liveness_watcher(&self, interval: Duration) { self.start_liveness_watcher_with_blocked_work(interval, DEFAULT_BLOCKED_WORK_INTERVAL); } /// [`Self::start_liveness_watcher`] with the blocked-work cadence (#83) /// made explicit. Tests pass a small `blocked_work_interval` to watch the /// probe actually happen. pub fn start_liveness_watcher_with_blocked_work( &self, interval: Duration, blocked_work_interval: Duration, ) { let weak = Arc::downgrade(&self.inner); tokio::spawn(async move { let mut ticker = tokio::time::interval(interval); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); // first tick fires immediately — skip it so we don't reap before // the caller has a chance to create anything. ticker.tick().await; // Start "due" so the first live tick probes: a recovered daemon // should not wait a full interval to show what blocks. let mut since_probe = blocked_work_interval; loop { ticker.tick().await; let Some(inner) = weak.upgrade() else { return; }; run_liveness_pass(&inner).await; // Same tick, no new timer (NAP-340): refresh activity for the // survivors and emit SessionUpdated on any transition. run_activity_pass(&inner).await; since_probe += interval; if since_probe >= blocked_work_interval { since_probe = Duration::ZERO; run_blocked_work_pass(&inner).await; } } }); } /// Re-attach to tmux sessions left behind by a previous daemon process. /// /// For each `maestro-` session we read its sidecar (or synthesise /// minimal metadata if missing), re-issue `pipe-pane` to a fresh FIFO, /// and start a `PtyBridge`. Sessions whose sidecar references a `cwd` /// that no longer exists are still adopted — the spec does not require /// us to repair them. pub async fn recover(&self) -> Result { tokio::fs::create_dir_all(&self.inner.fifo_dir).await?; let live = { let tmux = self.inner.tmux.clone(); tmux_blocking(move || tmux.list_sessions()).await? }; let mut adopted = 0usize; for id in live { let session = match self.inner.sidecar.read(id) { Ok(Some(s)) => s, _ => Session { id, cwd: PathBuf::from("/"), command: String::new(), env: Default::default(), metadata: Default::default(), worktree: None, status: SessionStatus::Running, created_at: Utc::now(), // Activity is unknown on adoption; the watcher fills it in. // #104: the restart also resets the activity clock to now — // accepted, since the daemon cannot know how long a // recovered session had already been sitting idle. foreground: None, activity: ActivityState::Idle, last_activity_at: Utc::now(), activity_since: chrono::Utc::now(), current_cwd: None, blocked_work: None, }, }; let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo")); let bridge = PtyBridge::start_with_replay(fifo_path.clone(), self.inner.replay_bytes).await?; // Re-pipe so output starts flowing again. `Tmux::pipe_pane` runs // without tmux's `-o` flag, so any stale pipe the previous daemon // left running to a now-unread FIFO is replaced (NAP-228). let _ = { let tmux = self.inner.tmux.clone(); let sink = pipe_pane_command(&fifo_path); tmux_blocking(move || tmux.pipe_pane(id, &sink)).await }; let mut sessions = self.inner.sessions.lock().await; sessions.insert( id, SessionRecord { session: session.clone(), fifo_path, // Restored from the sidecar: without it the recovered // session cleans up nothing on kill, and the leftover // directory blocks that branch forever (#54 review). worktree_path: session.worktree.as_ref().map(|w| w.path.clone()), worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()), // The sidecar carries no branch; a forced kill of a // recovered session probes git for it at retain time. worktree_branch: None, bridge, }, ); let _ = self .inner .events .send(CatalogEvent::SessionAdded { session }); adopted += 1; } Ok(adopted) } /// Forward keystrokes to a session's pane. A dead session is read-only — /// its pane is gone, so this is a no-op rather than a tmux failure the /// client would have to interpret. pub async fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), SessionError> { match self.status(id).await { None => return Err(SessionError::NotFound(id)), Some(SessionStatus::Dead) => return Ok(()), Some(SessionStatus::Running) => {} } let tmux = self.inner.tmux.clone(); let bytes = bytes.to_vec(); tmux_blocking(move || tmux.send_keys(id, &bytes)) .await .map_err(SessionError::Tmux) } /// Resize a session's window. No-op for a dead session, as with /// [`Self::send_keys`]. pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> { match self.status(id).await { None => return Err(SessionError::NotFound(id)), Some(SessionStatus::Dead) => return Ok(()), Some(SessionStatus::Running) => {} } let tmux = self.inner.tmux.clone(); tmux_blocking(move || tmux.resize_window(id, cols, rows)) .await .map_err(SessionError::Tmux) } } /// Run a blocking `tmux` subprocess call on the blocking thread pool so it /// never stalls a tokio worker. `tmux` is a fork+exec that can take tens of /// milliseconds on a loaded host, and `send_keys` rides the per-keystroke hot /// path — running it inline would starve HTTP handlers, the events WS, the /// liveness watcher, and PTY readers (NAP-256). /// Turn a request's environment into tmux `-e KEY=VAL` material. /// /// A key containing `=` or NUL would be parsed by tmux as a different /// assignment than the caller wrote, so it is rejected rather than silently /// reinterpreted. `BTreeMap` so the resulting argv is deterministic. fn sanitize_env( env: &std::collections::HashMap, ) -> Result, SessionError> { let mut out = std::collections::BTreeMap::new(); for (k, v) in env { if k.is_empty() || k.contains('=') || k.contains('\0') || v.contains('\0') { return Err(SessionError::InvalidEnv(k.clone())); } out.insert(k.clone(), v.clone()); } Ok(out) } async fn tmux_blocking(f: F) -> T where F: FnOnce() -> T + Send + 'static, T: Send + 'static, { tokio::task::spawn_blocking(f) .await .expect("tmux blocking task panicked") } /// What would be lost if this session's worktree were removed right now. /// /// The single [`crate::worktree_probe::in_the_way`] call site behind both /// endings of a session — the kill guard (#79) and the natural-exit /// classifier (#82) — so neither can grow its own answer. Runs on the /// blocking pool; callers invoke it before anything is torn down, because /// refusing (#79), recording a retention (#81) and classifying an exit (#82) /// must all see the tree as it stands. async fn probe_worktree( (_, path): &(PathBuf, PathBuf), ) -> Result { let path = path.clone(); tmux_blocking(move || crate::worktree_probe::in_the_way(&path)) .await .map_err(|e| SessionError::Worktree(crate::worktree::WorktreeError::from(e))) } /// Record a guarded worktree into the retained store with `reason`, BEFORE /// the caller tears anything down: a failed write then leaves every artifact /// untouched instead of destroying work nothing records anymore. Shared by /// the forced kill (`ForceKilled`, #81) and the natural exit /// (`ExitedWithUnsavedWork`, #82) so the two paths cannot drift apart. async fn record_retention( inner: &Arc, (base_repo, path): &(PathBuf, PathBuf), known_branch: Option, reason: RetainedReason, ) -> Result { let store = inner .retained .clone() .ok_or_else(|| SessionError::RetainFailed("no retained store is wired".into()))?; // Sessions adopted from a sidecar carry no branch; ask the tree itself. let branch = known_branch .or_else(|| crate::worktree_probe::current_branch(path)) .unwrap_or_default(); let base_repo = base_repo.clone(); let path = path.clone(); tmux_blocking(move || store.retain(&path, &base_repo, &branch, reason)) .await .map_err(|e| SessionError::RetainFailed(e.to_string())) } /// One pass of the liveness watcher: diff the in-memory session set against /// the tmux-reported one, and reap anything we still track but tmux has lost. async fn run_liveness_pass(inner: &Arc) { // Snapshot the in-memory ids first; if we own no sessions there is // nothing to reap and no reason to spawn a tmux subprocess (this also // avoids racing with the very first `new-session` that would otherwise // be competing to start the tmux server). // Only sessions we still believe are running can transition; a session // already marked Dead must not re-emit an update on every 2s tick. let known: Vec = { let sessions = inner.sessions.lock().await; if sessions.is_empty() { return; } sessions .iter() .filter(|(_, r)| r.session.status == SessionStatus::Running) .map(|(id, _)| *id) .collect() }; if known.is_empty() { return; } let live_ids = { let tmux = inner.tmux.clone(); match tmux_blocking(move || tmux.list_sessions()).await { Ok(ids) => ids, Err(e) => { tracing::warn!(error = ?e, "liveness watcher: tmux list-sessions failed"); return; } } }; let live: HashSet = live_ids.into_iter().collect(); for id in known { if !live.contains(&id) { mark_session_dead(inner, id).await; } } } /// Refresh each live session's inferred activity (NAP-340) and emit /// `SessionUpdated` only when `activity` or `foreground` actually changes — so a /// steady state produces no diffs. Runs on the existing liveness tick. async fn run_activity_pass(inner: &Arc) { let ids: Vec = { let sessions = inner.sessions.lock().await; if sessions.is_empty() { return; } // A dead session has no pane to inspect; its last observed activity is // kept as-is so the UI can still show what it was doing when it died. sessions .iter() .filter(|(_, r)| r.session.status == SessionStatus::Running) .map(|(id, _)| *id) .collect() }; if ids.is_empty() { return; } // One `/proc` walk for the whole pass, rather than one per session. Reading // it is pure filesystem work, so it goes on the blocking pool alongside the // tmux calls below. let groups = tmux_blocking(crate::proc::ProcGroups::snapshot).await; for id in ids { // The tmux pane lookup is a subprocess — keep it off the runtime worker // and outside the sessions lock. let pane = { let tmux = inner.tmux.clone(); match tmux_blocking(move || tmux.pane_info(id)).await { Ok(Some(p)) => p, // Session/pane vanished — the liveness pass reaps it; skip. Ok(None) => continue, Err(e) => { tracing::debug!(%id, error = ?e, "activity: pane_info failed"); continue; } } }; // `/proc` inspection is cheap, but still done off the async lock. let awaiting = crate::proc::awaiting_input(pane.pane_pid, &pane.tty, &groups); let updated = { let mut sessions = inner.sessions.lock().await; let Some(rec) = sessions.get_mut(&id) else { continue; }; let idle = rec.bridge.idle_for(); let activity = crate::activity::decide(idle, awaiting); let foreground = Some(pane.command.clone()); // The pane's live cwd, drives the dir-based session tree; empty means // tmux reported none. let current_cwd = (!pane.current_path.is_empty()) .then(|| std::path::PathBuf::from(&pane.current_path)); // Change-detection on (activity, foreground, current_cwd) is the // debounce: nothing emitted at steady state. if rec.session.activity == activity && rec.session.foreground == foreground && rec.session.current_cwd == current_cwd { None } else { // #104: stamp only when the state itself changed — never on // foreground/cwd-only diffs, and never every tick, or the // sidebar's duration would reset continuously and read zero. if rec.session.activity != activity { rec.session.activity_since = Utc::now(); } rec.session.activity = activity; rec.session.foreground = foreground; rec.session.current_cwd = current_cwd; // Wall-clock of the last output ≈ now − idle. rec.session.last_activity_at = Utc::now() - chrono::Duration::from_std(idle).unwrap_or_else(|_| chrono::Duration::zero()); Some(rec.session.clone()) } }; if let Some(session) = updated { let _ = inner.events.send(CatalogEvent::SessionUpdated { session }); } } } /// Refresh each running session's blocked-work indicator (#83): what its /// worktree holds right now that would block an unforced kill. Rides the /// liveness tick on its own (slower) cadence — see /// [`DEFAULT_BLOCKED_WORK_INTERVAL`] — because every probe is three git /// subprocesses per session. /// /// Sessions without a worktree are filtered out BEFORE any probe: they can /// never block a kill, so their indicator must cost nothing at all. A failed /// probe keeps the previous value rather than clearing it — a transient git /// error must not read as "nothing blocks". Like the activity pass, this only /// emits `SessionUpdated` when the value actually changed. async fn run_blocked_work_pass(inner: &Arc) { let targets: Vec<(Uuid, (PathBuf, PathBuf))> = { let sessions = inner.sessions.lock().await; sessions .iter() .filter(|(_, r)| r.session.status == SessionStatus::Running) .filter_map(|(id, r)| { let wt = r.worktree_base_repo.clone().zip(r.worktree_path.clone())?; Some((*id, wt)) }) .collect() }; if targets.is_empty() { return; } for (id, wt) in targets { // The kill guard's own probe (#79), so the indicator can never // disagree with what a kill attempt would find. let breakdown = match probe_worktree(&wt).await { Ok(breakdown) => breakdown, Err(e) => { tracing::debug!(%id, error = ?e, "blocked-work: probe failed; keeping previous value"); continue; } }; let updated = { let mut sessions = inner.sessions.lock().await; let Some(rec) = sessions.get_mut(&id) else { continue; }; if rec.session.blocked_work == Some(breakdown) { None } else { rec.session.blocked_work = Some(breakdown); Some(rec.session.clone()) } }; if let Some(session) = updated { let _ = inner.events.send(CatalogEvent::SessionUpdated { session }); } } } /// Mark a session whose tmux session has disappeared as [`SessionStatus::Dead`] /// and settle its worktree per the natural-exit rule (#82). /// /// The record, its [`PtyBridge`] and its sidecar all survive, so a client can /// still attach and read the replay buffer — the last thing a crashed agent /// printed. The worktree, though, is settled right away: a pristine tree is /// removed, anything else is retained into the store with reason /// `exited-with-unsaved-work` (#82) — nobody is waiting for an answer here, /// so there is nothing to refuse. Once settled, the session drops its /// worktree reference so a later dismissal neither re-probes nor retains /// twice. When settling is impossible (probe or store failure) nothing is /// destroyed: the tree stays attached to the dead record for a later forced /// dismissal (#81). /// /// Dead sessions do not survive a daemon restart: [`SessionManager::recover`] /// adopts only what tmux still holds. async fn mark_session_dead(inner: &Arc, id: Uuid) { let flipped = { let mut sessions = inner.sessions.lock().await; match sessions.get_mut(&id) { Some(record) if record.session.status == SessionStatus::Running => { record.session.status = SessionStatus::Dead; // Release anyone attached; the scrollback survives for a later // attach. record.bridge.seal(); let session = record.session.clone(); let worktree = record .worktree_base_repo .clone() .zip(record.worktree_path.clone()); let branch = record.worktree_branch.clone(); Some((session, worktree, branch)) } _ => None, } }; let Some((mut session, worktree, branch)) = flipped else { return; }; tracing::info!(%id, "session vanished from tmux — marking dead"); if let Some(wt) = &worktree { if settle_worktree_on_exit(inner, id, wt, branch).await { // The tree is settled (removed or retained); a stale "in the // way" would describe something the session no longer holds. session.blocked_work = None; let mut sessions = inner.sessions.lock().await; if let Some(record) = sessions.get_mut(&id) { record.session.worktree = None; record.session.blocked_work = None; record.worktree_path = None; record.worktree_base_repo = None; record.worktree_branch = None; } } } let _ = inner.sidecar.write(&session); let _ = inner.events.send(CatalogEvent::SessionUpdated { session }); } /// Apply the exit rule (#82) to a vanished session's worktree: probe what /// would be lost through the kill's own predicate, then remove a pristine /// tree or retain anything else under `exited-with-unsaved-work` — recorded /// before anything is torn down, exactly as the forced kill does (#81). A /// removal that fails falls back to retaining, since whatever blocked it is /// by definition something worth keeping. /// /// Returns whether the worktree was fully accounted for. When it wasn't, /// nothing was destroyed and the caller keeps the reference on the record. async fn settle_worktree_on_exit( inner: &Arc, id: Uuid, (base_repo, path): &(PathBuf, PathBuf), known_branch: Option, ) -> bool { let probe = match probe_worktree(&(base_repo.clone(), path.clone())).await { Ok(probe) => probe, Err(e) => { tracing::error!( %id, path = %path.display(), error = ?e, "natural exit: could not probe the worktree; leaving it untouched" ); return false; } }; if probe.is_clear() { match inner.worktree_mgr.remove(base_repo, path) { Ok(()) => { tracing::info!(%id, path = %path.display(), "natural exit: pristine worktree removed"); return true; } Err(e) => { tracing::warn!( %id, path = %path.display(), error = ?e, "natural exit: removal failed; falling back to retention" ); } } } match record_retention( inner, &(base_repo.clone(), path.clone()), known_branch, RetainedReason::ExitedWithUnsavedWork, ) .await { Ok(entry) => { tracing::info!( %id, path = %path.display(), entry = %entry.id, "natural exit: worktree retained with unsaved work" ); true } Err(e) => { tracing::error!( %id, path = %path.display(), error = ?e, "natural exit: could not record the retained worktree; leaving it untouched" ); false } } } /// Tear down a session's artifacts after it has already been removed from the /// live map. Shared by [`SessionManager::kill`] and [`reap_dead_session`], /// which previously hand-rolled the same five-step choreography and only /// differed in whether tmux still had a session to kill (NAP-273). /// /// Best-effort: every step is fire-and-forget because the session is already /// gone from the authoritative in-memory map, so a stray FIFO or sidecar can't /// resurrect it. `kill_tmux` is `true` for an explicit kill and `false` for the /// liveness reaper (tmux already lost the session). /// /// `retain_worktree` is `true` for a forced kill (#81) that retained the /// worktree instead of removing it: the store entry is already written, so /// only the removal is skipped. async fn cleanup_record( inner: &Arc, id: Uuid, record: SessionRecord, kill_tmux: bool, retain_worktree: bool, ) { if kill_tmux { let tmux = inner.tmux.clone(); let _ = tmux_blocking(move || tmux.kill_session(id)).await; } let _ = std::fs::remove_file(&record.fifo_path); if let Err(e) = inner.sidecar.delete(id) { tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar"); } if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) { if retain_worktree { tracing::info!( %id, path = %path.display(), "forced kill: worktree retained in the store" ); } else if let Err(e) = inner.worktree_mgr.remove(base, path) { // 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. tracing::warn!( %id, path = %path.display(), error = %e, "left worktree on disk" ); } } let _ = inner.events.send(CatalogEvent::SessionRemoved { id }); }