The activity pass now sets activity_since only when the decided ActivityState differs from the stored one; foreground/cwd-only diffs and steady-state ticks leave the clock alone, so the sidebar duration counts time in the current state instead of reading zero. Daemon restarts still reset the clock to the restart time (stated in recover()); the daemon cannot know how long a recovered session had already been idle.
1122 lines
46 KiB
Rust
1122 lines
46 KiB
Rust
//! 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<PathBuf>,
|
||
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
|
||
/// worktree remove` is invoked from the base repo.
|
||
pub worktree_base_repo: Option<PathBuf>,
|
||
/// 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<String>,
|
||
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<Inner>,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
struct Inner {
|
||
tmux: Arc<dyn Panes>,
|
||
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<SpawnHistory>,
|
||
/// Retained-worktree store (#78). `None` when not wired (tests); a forced
|
||
/// kill (#81) without one fails rather than destroying unrecorded work.
|
||
retained: Option<crate::retained_store::RetainedStore>,
|
||
/// 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<Option<String>>,
|
||
sessions: Mutex<HashMap<Uuid, SessionRecord>>,
|
||
events: broadcast::Sender<CatalogEvent>,
|
||
}
|
||
|
||
impl SessionManager {
|
||
pub fn new(
|
||
tmux: Arc<dyn Panes>,
|
||
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<dyn Panes>,
|
||
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<Inner>` 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<RecentDir> {
|
||
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<RecentDir> {
|
||
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<CatalogEvent> {
|
||
self.inner.events.subscribe()
|
||
}
|
||
|
||
/// Snapshot of every live session. Suitable for `SessionsSnapshot`.
|
||
pub async fn snapshot(&self) -> Vec<Session> {
|
||
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<broadcast::Receiver<Vec<u8>>> {
|
||
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<u8>, broadcast::Receiver<Vec<u8>>)> {
|
||
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<SessionStatus> {
|
||
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<Session, SessionError> {
|
||
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<PathBuf>, path: &Option<PathBuf>) {
|
||
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<KillOutcome, SessionError> {
|
||
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<Inner>` 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-<uuid>` 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<usize, SessionError> {
|
||
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<String, String>,
|
||
) -> Result<std::collections::BTreeMap<String, String>, 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<T, F>(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<maestro_protocol::InTheWay, SessionError> {
|
||
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<Inner>,
|
||
(base_repo, path): &(PathBuf, PathBuf),
|
||
known_branch: Option<String>,
|
||
reason: RetainedReason,
|
||
) -> Result<crate::retained_store::RetainedWorktreeRecord, SessionError> {
|
||
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<Inner>) {
|
||
// 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<Uuid> = {
|
||
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<Uuid> = 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<Inner>) {
|
||
let ids: Vec<Uuid> = {
|
||
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<Inner>) {
|
||
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<Inner>, 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<Inner>,
|
||
id: Uuid,
|
||
(base_repo, path): &(PathBuf, PathBuf),
|
||
known_branch: Option<String>,
|
||
) -> 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<Inner>,
|
||
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 });
|
||
}
|