trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
//! Single-record soft-delete for managed sessions (#2012, `--deleted--` marker).
//!
//! Why: `decommission` stops the runtime and (when owned) removes the
//! workspace, but ALWAYS leaves a `Decommissioned` tombstone behind — an
//! operator with a mis-provisioned or duplicate record has no single-record
//! verb to mark it gone. `tm sessions delete` fills that gap. Rather than
//! silently dropping the record from the store (which made a deleted session
//! VANISH from the master list), delete now marks it
//! [`ManagedSessionState::Deleted`] — rendered `--deleted--` — so the record is
//! still tracked and visible, honouring the "fully-tracked lifecycle, no
//! fire-and-forget" project standard. Permanent removal from the store then
//! happens through the existing prune path (`tm sessions prune --state deleted`
//! / `--state all`). Extracted into its own file (mirroring
//! `adopt.rs`/`decommission.rs`/`prune.rs`) so [`super::prune`] stays under the
//! 500-SLOC production cap.
//! What: an inherent `impl SessionManager` block adding
//! [`SessionManager::delete_record`], guarded by the SAME fail-closed running
//! guard `super::prune::is_running` enforces elsewhere — a real tmux liveness
//! probe, not a persisted-state check (#2022). #8935: the probe asks whether the live
//! session holds the record's own pane, so a name another session reused does
//! not block, or get blamed on, a stale record's delete.
//! Test: `delete_record_*` in `super::delete_tests`.

use chrono::Utc;

use super::manager::{ManagedError, SessionManager};
use super::record::{ManagedSessionId, ManagedSessionState, SessionRecord};
use super::runtime_identity;

impl SessionManager {
    /// Soft-delete a single managed session RECORD — mark it `--deleted--` (#2012).
    ///
    /// Why: distinct from `decommission` (stops the runtime and may remove the
    /// workspace, always leaving a `Decommissioned` tombstone) — this marks the
    /// record [`ManagedSessionState::Deleted`] (rendered `--deleted--`) so the
    /// operator's master list REFLECTS the deletion rather than the row
    /// silently vanishing. Guarded by the SAME fail-closed running-state check
    /// [`prune_managed`](Self::prune_managed) enforces, so a live session can
    /// never be deleted out from under its runtime by accident.
    ///
    /// SAFETY: this is a STORE-ONLY state transition — it re-`upsert`s the record
    /// with `state = Deleted`, mutating only the in-memory map and the persisted
    /// `sessions.json`. It never touches `workspace_path` on disk (unlike
    /// `decommission`, which may `remove_dir_all` an owned workspace). Marking
    /// the record deleted is deliberately NOT the same operation as tearing down
    /// the workspace; an operator who also wants the workspace gone should
    /// `decommission` first. To drop the tombstone from the store entirely, use
    /// `tm sessions prune --state deleted` (or `--state all`).
    ///
    /// What: looks up the record (a missing id surfaces as
    /// [`ManagedError::SessionNotFound`]). When `force` is `false` (the default)
    /// and a LIVE tmux session may back the record (#2022 — a real probe, not
    /// the persisted `state` field; #8935 — see [`Self::delete_record_reporting`]),
    /// returns
    /// [`ManagedError::InvalidState`] with an actionable message telling the
    /// operator to stop the session first or pass `--force` — no record is
    /// touched. That message names the session's FRIENDLY name and puts the full
    /// UUID alone on a trailing line (#7224), so a width-clamped surface — the
    /// `tm ls` TUI status line — can wrap it without cutting the id in half.
    /// A record whose `state` still says `Active`/`Provisioning` but
    /// whose tmux session is actually gone is NOT running by this probe, so it
    /// deletes cleanly without `--force`. Otherwise transitions the record to
    /// `Deleted`, persists it, and returns the PRE-deletion [`SessionRecord`]
    /// snapshot (so callers can render the state it was in before deletion).
    /// A liveness probe that could not reach tmux at all is neither "running"
    /// nor "not running": its error surfaces as
    /// [`ManagedError::TmuxUnavailable`] and no record is touched (#5859).
    /// #8942: a non-terminal record of a protected kind is refused with
    /// [`ManagedError::InvalidState`], even with `force`.
    /// Test: `delete_refuses_a_live_supervisor_record`, `delete_record_marks_deleted`,
    /// `delete_record_refuses_when_the_tmux_probe_fails` (#5859),
    /// `delete_record_refuses_running_without_force`,
    /// `delete_record_force_bypasses_running_guard`,
    /// `delete_record_never_touches_workspace_dir`,
    /// `delete_record_stale_active_deletable_when_tmux_dead` (#2022) in
    /// `super::delete_tests`; the #7087 residency bump and cache eviction by
    /// `generation_increments_across_forced_delete` and
    /// `forced_delete_evicts_the_residency_cache_entry` in
    /// `daemon::managed_routes::residency`'s route tests.
    pub async fn delete_record(
        &self,
        id: &ManagedSessionId,
        force: bool,
    ) -> Result<SessionRecord, ManagedError> {
        Ok(self.delete_record_reporting(id, force).await?.0)
    }

    /// [`Self::delete_record`], also returning the note to show when a live
    /// tmux session carries the record's name (#8935).
    ///
    /// Why: a delete never touches tmux. When a session with the record's name
    /// is still up, the command output must say it was left running, and
    /// whether it is this record's or another session that took the name.
    /// What: the same delete. The running guard asks
    /// [`runtime_identity::runtime_ownership`] instead of name membership, so
    /// a live session proved NOT to hold the record's pane (`Foreign`) no
    /// longer blocks the delete of a stale record; `Owned` and `Unverifiable`
    /// still need `force`. A failed probe refuses without `force` (#5859) and,
    /// with `force`, reads as `Unverifiable` so the delete proceeds and the
    /// note says the probe failed. The second value is
    /// [`runtime_identity::RuntimeOwnership::left_running_note`].
    /// Test: `deleting_a_stale_record_leaves_the_live_session_and_says_so`,
    /// `delete_refuses_an_unverifiable_live_name_without_force`,
    /// `a_forced_delete_survives_a_failed_tmux_probe`.
    pub async fn delete_record_reporting(
        &self,
        id: &ManagedSessionId,
        force: bool,
    ) -> Result<(SessionRecord, Option<String>), ManagedError> {
        let record = self.get(id).await?;
        // #8942 critic MEDIUM: a live Architect record is never tombstoned,
        // `--force` or not; the kind is what keeps the floor protecting it.
        super::supervisor::refuse_protected(&record, super::supervisor::ProtectedVerb::Delete)?;
        // #5859: `?` — a probe that could not reach tmux refuses the delete
        // instead of answering "not running" and dropping a live session.
        // #8935: the guard asks whose the live session is, not only whether
        // the name is live; a session that took a stale record's name since
        // does not make the stale record "running".
        // #8935 critic round: `--force` asks for the record to go whatever
        // tmux says, so under `force` a failed probe is a note, not an error.
        let ownership = match runtime_identity::runtime_ownership(&record, self.tmux.as_ref()) {
            Err(e) if force => runtime_identity::RuntimeOwnership::Unverifiable(format!(
                "tmux probe failed ({e}), so any live tmux session named '{}' \
                 could not be checked",
                record.tmux_name
            )),
            probed => probed?,
        };
        if !force && ownership.may_be_ours() {
            // #7224: the refusal used to inline the 36-character UUID twice, mid
            // sentence, which any width-clamped surface cut mid-token. The
            // friendly name carries the identity; the id gets its own line.
            return Err(ManagedError::InvalidState(
                id.to_string(),
                format!(
                    "session '{name}' is {state} — stop it first with \
                     `tm session stop` (or `tm session decommission`), or pass \
                     --force to delete the record anyway. Session id:\n{id}",
                    name = record.tmux_name,
                    state = record.state,
                ),
            ));
        }
        // Soft-delete: mark the record `Deleted` (rendered `--deleted--`) and
        // persist. The record stays in the store — visible in the master list —
        // rather than being dropped outright.
        let mut updated = record.clone();
        // Stamps `terminal_at` as well as `state` — the retention sweep's clock
        // starts here, not at `created_at`.
        updated.set_lifecycle_state(ManagedSessionState::Deleted, Utc::now());
        updated.last_activity_at = Some(Utc::now());
        // #4400: `Deleted` is terminal — same reasoning as `decommission`'s
        // clear (`decommission.rs`): no human will ever act on a
        // `pending_decision` raised before a soft-delete, so leaving it set
        // would let `supervisor_status`'s human-confirmation queue accumulate
        // another phantom-gate source alongside decommissioned records.
        updated.pending_decision = None;
        updated.proposed_default = None;
        self.store.write().await.upsert(updated).await?;
        // #7087: a soft-delete leaves the active-project set — `--force`
        // reaches `Deleted` straight from `Active`/`Provisioning`, without
        // passing through `stop`, so this is the only bump on that path.
        self.bump_residency_generation();
        // #7087: `Deleted` is terminal, so nothing will ask the residency
        // route about this session again — drop its derivation entry.
        self.residency_cache_evict(id).await;
        // #8935: delete is record-only; say so when a live session remains.
        let note = ownership.left_running_note(&record.tmux_name);
        Ok((record, note))
    }
}