1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
//! 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).
//! Test: `delete_record_*` in `super::delete_tests`.
use chrono::Utc;
use super::manager::{ManagedError, SessionManager};
use super::prune::is_running;
use super::record::{ManagedSessionId, ManagedSessionState, SessionRecord};
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 [`is_running`] finds a LIVE tmux session backing the record (#2022 — a
/// real probe, not the persisted `state` field), 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).
/// Test: `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> {
let record = self.get(id).await?;
// #5859: `?` — a probe that could not reach tmux refuses the delete
// instead of answering "not running" and dropping a live session.
if !force && is_running(&record, self.tmux.as_ref())? {
// #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;
Ok(record)
}
}