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
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
//! 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))
}
}