trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
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
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
//! Rename a managed session — update `tmux_name` + the live tmux entity.
//!
//! Why: operators need to give a managed session a meaningful name, both from
//! the master list (`tm sessions rename <id-or-name> <new-name>`) and from
//! within a session (`tm sessions rename <new-name>`, resolved from
//! `$TM_MANAGED_SESSION_ID`). A managed session's identity name IS its
//! `tmux_name` (there is no separate label field), so a rename updates that
//! field AND renames the live tmux session so `tmux attach`/`list-sessions`
//! stay consistent with the record. Extracted into its own file (mirroring
//! `delete.rs`) so `manager.rs` stays under the 500-SLOC production cap.
//! What: [`validate_session_name`] (a pure, unit-testable name check) plus an
//! inherent `impl SessionManager` block adding [`SessionManager::rename`], which
//! validates the name, guards against collisions (with another record OR a live
//! tmux session), renames the live tmux session when one exists, updates
//! `tmux_name`, and persists.
//! Test: `rename_*` in `super::rename_tests`.

use std::collections::HashMap;
use std::sync::{Arc, LazyLock, Mutex, PoisonError};

use chrono::Utc;
use tokio::sync::OwnedMutexGuard;
use tracing::warn;

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

/// Validate a proposed session name, returning the trimmed value or a message.
///
/// Why: a tmux session name cannot be empty or contain whitespace, and tmux
/// treats `.`/`:` as reserved target separators — rejecting a bad name up front
/// with an actionable message beats a confusing downstream tmux failure.
/// What: trims `name`, then rejects an empty value, one over 64 chars, or any
/// character outside `[A-Za-z0-9_-]` (the alphabet the existing
/// `tm-<leaf>-NN`/`tm-<adjective>-<noun>` names already use). Returns the
/// trimmed, validated name on success.
///
/// `pub` rather than `pub(crate)` since #7224: the `tm ls` session TUI renames
/// inline, and a client-side pre-check that is a SEPARATE implementation of
/// this rule would eventually disagree with the daemon about which names are
/// valid. Calling this exact function makes that impossible — the TUI shows the
/// same message the PATCH would have returned, without the round trip.
/// Test: `validate_session_name_*` in `super::rename_tests`;
/// `state_rename_rejects_an_invalid_name_with_the_shared_validator` in
/// `bin/tm/commands/session_tui/tests.rs` pins the sharing.
pub fn validate_session_name(name: &str) -> Result<String, String> {
    let trimmed = name.trim();
    if trimmed.is_empty() {
        return Err("session name must not be empty".to_string());
    }
    if trimmed.chars().count() > 64 {
        return Err(format!(
            "session name '{trimmed}' is too long (max 64 characters)"
        ));
    }
    if !trimmed
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
    {
        return Err(format!(
            "session name '{trimmed}' has invalid characters — \
             use letters, digits, '-', and '_' only"
        ));
    }
    Ok(trimmed.to_string())
}

impl SessionManager {
    /// Rename a managed session — set its `tmux_name` and rename the live tmux
    /// session to match.
    ///
    /// Why: a managed session's user-facing identity IS its `tmux_name`;
    /// renaming must therefore update the record AND the live tmux entity in
    /// lock-step so a later `tmux attach`/`ls` never shows a stale name. A
    /// stopped session has no live tmux, so only its record changes.
    ///
    /// What: validates `new_name` via [`validate_session_name`] (an invalid
    /// name surfaces as [`ManagedError::InvalidState`]). Looks up the record (a
    /// missing id surfaces as [`ManagedError::SessionNotFound`]). A rename to
    /// the SAME name is a no-op that returns the record unchanged. A terminal
    /// record (`Decommissioned`/`Deleted`) is refused with `InvalidState`.
    /// Auto-suffixes — NEVER rejects (owner decision, issue #3692) — a name
    /// already taken by another NON-terminal managed record or by a live tmux
    /// session, via [`SessionManager::taken_name_set`] +
    /// [`SessionManager::dedupe_name_against`] (a terminal tombstone's name is
    /// reusable as-is, unsuffixed).
    ///
    /// Locking design (#3692 review HIGH-3, revised by #3698 round-2 HIGH-A):
    /// tmux subprocess calls are NEVER made while the store write guard is
    /// held — `store.write()` gates every session-manager mutation daemon-wide,
    /// and `rename_session`/`list_sessions` are blocking fork/exec/waits (a
    /// hung tmux under the guard would stall the whole daemon; every other
    /// write path in this module keeps tmux outside the lock). Instead:
    /// the live-tmux snapshot is fetched FIRST (unlocked); then lookup +
    /// collision-check/dedupe run under the guard. A record with NO live tmux
    /// (stopped) persists under that SAME guard — this is the HIGH-3
    /// guarantee: two concurrent stopped-session renames to one target are
    /// fully serialized and can never both claim the same name. A record WITH
    /// a live tmux session releases the guard, renames tmux (unlocked), then
    /// RE-ACQUIRES the guard and RE-VERIFIES before persisting that (a) the
    /// record still exists, non-terminal, still under its old name, and (b)
    /// nobody claimed the deduped name while the lock was released; on
    /// re-verify failure the tmux rename is rolled back and a retryable
    /// `InvalidState` error is returned. If the store write itself fails
    /// after the tmux rename, the tmux rename is rolled back so the live
    /// session and the record never desync; a failed rollback surfaces an
    /// explicit manual-recovery error. Returns the updated [`SessionRecord`]
    /// (its `tmux_name` may differ from the requested `new_name` if it was
    /// auto-suffixed).
    ///
    /// Tmux-identity guard (#3714 remediation): "does a live tmux entity need
    /// renaming" is decided by this record's OWN recorded `pane_id` — verified
    /// pane-scoped via `ManagedTmuxDriver::pane_exists` — never by a bare
    /// name-string membership check against `list_sessions`. A duplicate-name
    /// record (the #3692 condition) whose own pane is gone must never have its
    /// rename physically retarget an UNRELATED live session that happens to
    /// hold the same name; when that mismatch is detected, only the DB record
    /// is renamed (with a `warn!` notice) and the live tmux entity is left
    /// untouched. #9101: `owned_session_id` also requires the pane to be
    /// on the server the record captured it on, and refuses the rename when
    /// ownership cannot be proved.
    /// Test: `rename_updates_name_and_persists`,
    /// `rename_same_name_is_noop`, `rename_suffixes_collision_with_record`,
    /// `rename_suffixes_collision_with_live_tmux`,
    /// `rename_suffix_skips_to_next_free_ordinal`,
    /// `rename_concurrent_stopped_renames_to_same_target_never_collide`,
    /// `rename_rejects_invalid_name`, `rename_rejects_terminal_record`,
    /// `rename_renames_live_tmux_session`,
    /// `rename_never_renames_unrelated_live_session_sharing_a_stale_name`,
    /// `rename_renames_live_session_when_pane_confirmed_alive`,
    /// `rename_reuses_name_freed_by_a_deleted_record` in `super::rename_tests`;
    /// `two_concurrent_renames_of_one_live_record_leave_tmux_and_the_record_in_step`
    /// in `super::rename_race_tests` (one record's renames are serialized).
    pub async fn rename(
        &self,
        id: &ManagedSessionId,
        new_name: &str,
    ) -> Result<SessionRecord, ManagedError> {
        let new_name = validate_session_name(new_name)
            .map_err(|msg| ManagedError::InvalidState(id.to_string(), msg))?;
        // #9101: renaming by `$N` id dropped the implicit `=old` check that
        // failed the second of two racing renames at tmux; this record's
        // renames now wait for each other, snapshot through Guard 2.
        let _turn = RenameTurn::take(*id).await;

        // Live-tmux snapshot BEFORE the guard (#3698 round-2 HIGH-A):
        // `list_sessions` is a blocking tmux subprocess and must never run
        // under the store write lock that gates every other mutation.
        let live_names = self
            .tmux
            .list_sessions()
            .map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))?;
        // #9101: ownership is proved from a snapshot before the guard too, for
        // the same reason; Guard 1 refuses if the record moved since.
        let snapshot = self.get(id).await?;
        let name_live = live_names.iter().any(|n| n == &snapshot.tmux_name);
        let owned_session =
            if name_live && snapshot.tmux_name != new_name && !snapshot.state.is_terminal() {
                self.owned_session_id(id, &snapshot)?
            } else {
                None
            };

        // ── Guard 1: lookup + collision-check/dedupe (no tmux calls held).
        // NOTE: do not call `self.get`/`self.list`/`self.dedupe_session_name`
        // in here — they take this same lock (deadlock); read through the
        // guard.
        let mut store = self.store.write().await;
        let records = store.all().await?;
        let record = records
            .iter()
            .find(|r| r.id == *id)
            .cloned()
            .ok_or_else(|| ManagedError::SessionNotFound(id.to_string()))?;
        if !same_tmux_identity(&record, &snapshot) || record.state != snapshot.state {
            return Err(ManagedError::InvalidState(
                id.to_string(),
                "the record changed while its tmux session was being checked; \
                 nothing was renamed — retry the rename"
                    .into(),
            ));
        }
        if record.tmux_name == new_name {
            // Renaming to the current name is a no-op — nothing to persist.
            return Ok(record);
        }
        if record.state.is_terminal() {
            return Err(ManagedError::InvalidState(
                id.to_string(),
                format!(
                    "session is {} — a terminal (decommissioned/deleted) record cannot be renamed",
                    record.state
                ),
            ));
        }

        // Auto-suffix on collision — NEVER reject (owner decision, issue
        // #3692): a name already held by another non-terminal managed record
        // or a live tmux session (managed or foreign) is disambiguated with
        // the smallest free `-N` ordinal rather than refusing the rename. A
        // TERMINAL tombstone's `tmux_name` does NOT count as taken (it is gone
        // for good) — a name freed by delete/decommission is reused as-is,
        // unsuffixed; only `prune` still holds the on-disk tombstone.
        let taken = Self::taken_name_set(&live_names, &records, Some(id));
        let new_name = Self::dedupe_name_against(&new_name, &taken);

        let old_name = record.tmux_name.clone();
        // #3714 (remediation addendum): a bare `live_names.contains(old_name)`
        // proves only that SOME tmux session is currently using this NAME — a
        // duplicate-name record (the #3692 condition) can have its OWN pane
        // long gone while an UNRELATED, genuinely live session (owned by a
        // DIFFERENT record) still happens to hold that exact name. Physically
        // renaming "whatever tmux session currently holds `old_name`" in that
        // case hijacks the unrelated live session out from under its own
        // operator — the exact remediation-comment incident (`tm sessions
        // rename` retitled the attached `tm-tagents` session while trying to
        // rename an unrelated stale duplicate). When this record has a
        // captured `pane_id`, liveness is only trusted once the driver
        // confirms THAT SPECIFIC pane still lives inside a session named
        // `old_name` (`pane_exists` is pane-scoped, never a name-string
        // match) — tying the check to this record's OWN tmux identity.
        // #9101: and only on the server the record captured the pane on, so a
        // restarted server's session that reused the name and `%N` keeps its
        // name. Another session's name is renamed record-only, as #3714 did;
        // an ownership that cannot be proved refuses the whole rename. The
        // verdict was read before the guard (`owned_session`).
        if name_live && owned_session.is_none() {
            warn!(
                id = %id,
                name = %old_name,
                "rename: a tmux session named '{old_name}' is live but does not contain \
                 this record's own recorded pane — it belongs to a DIFFERENT session \
                 (duplicate-name collision, #3714); renaming the DB record only and \
                 leaving the live tmux session untouched"
            );
        }

        let mut updated = record;
        updated.tmux_name = new_name.clone();
        updated.last_activity_at = Some(Utc::now());

        let Some(session_id) = owned_session else {
            // Stopped path: nothing external to mutate — persist under the
            // SAME guard (#3692 HIGH-3): two concurrent stopped-session
            // renames are fully serialized here, so the second recomputes its
            // ordinal AFTER the first persisted and they can never collide.
            store.upsert(updated.clone()).await?;
            return Ok(updated);
        };
        drop(store);

        // Live path: rename tmux OUTSIDE any lock (#3698 round-2 HIGH-A) —
        // a slow/hung tmux must never stall the daemon-wide store guard.
        // #9101: by the `$N` id ownership was proved for, never by name.
        self.tmux
            .rename_session_id(&old_name, &session_id, &new_name)?;
        let undo = Rollback {
            session_id: &session_id,
            new_name: &new_name,
            old_name: &old_name,
            id,
        };

        // ── Guard 2: re-verify, then persist. The lock was released across
        // the tmux call, so both sides of the check-then-act must be
        // re-established before the write.
        let mut store = self.store.write().await;
        let records = match store.all().await {
            Ok(r) => r,
            Err(e) => {
                drop(store);
                return Err(self.rollback_rename(&undo, &e.to_string()));
            }
        };
        // #9101: the pane and server too, so a record re-bound to another
        // pane while tmux ran is never persisted under the new name.
        let self_unchanged = records
            .iter()
            .any(|r| r.id == *id && same_tmux_identity(r, &snapshot) && !r.state.is_terminal());
        let name_still_free = !records
            .iter()
            .any(|r| r.id != *id && r.tmux_name == new_name && !r.state.is_terminal());
        if !(self_unchanged && name_still_free) {
            drop(store);
            return Err(self.rollback_rename(
                &undo,
                "lost a concurrent rename race while tmux was being renamed — retry the rename",
            ));
        }
        if let Err(e) = store.upsert(updated.clone()).await {
            drop(store);
            return Err(self.rollback_rename(&undo, &e.to_string()));
        }
        Ok(updated)
    }

    /// The `$N` id of the live session named `record.tmux_name` when it is
    /// the record's own, so a rename may retitle it (#9101).
    ///
    /// What: [`runtime_ownership`]: `Owned` is its session id; `Absent` and
    /// `Foreign` (the pane is gone, or on another server) are `None`, a
    /// record-only rename. `Unverifiable` (no pane id, no server identity, an
    /// unlistable pane set or an unreadable identity) and a failed probe are
    /// an `Err`, so nothing is renamed. Runs with no store guard held.
    /// Test: `a_stale_record_after_a_server_restart_never_renames_the_live_session`,
    /// `an_unreadable_pane_identity_refuses_the_rename`,
    /// `rename_never_renames_unrelated_live_session_sharing_a_stale_name`,
    /// `rename_proves_ownership_with_no_store_guard_held`.
    fn owned_session_id(
        &self,
        id: &ManagedSessionId,
        record: &SessionRecord,
    ) -> Result<Option<String>, ManagedError> {
        match runtime_ownership(record, self.tmux.as_ref())? {
            RuntimeOwnership::Owned { session_id, .. } => Ok(Some(session_id)),
            RuntimeOwnership::Absent | RuntimeOwnership::Foreign(_) => Ok(None),
            RuntimeOwnership::Unverifiable(why) => Err(ManagedError::InvalidState(
                id.to_string(),
                format!("refusing to rename: {why}; nothing was renamed"),
            )),
        }
    }

    /// Roll a half-applied tmux rename back (`new` → `old`) and build the
    /// error the caller should surface — shared by `rename`'s re-verify and
    /// store-write failure paths.
    ///
    /// Why: once tmux has been renamed OUTSIDE the store guard (#3698 round-2
    /// HIGH-A), any later failure — reload error, lost re-verify race, failed
    /// upsert — leaves the live session and the record desynced unless the
    /// tmux rename is compensated. Centralising the rollback keeps all three
    /// failure paths byte-identical.
    /// What: renames the session `undo.session_id` back by id; on success
    /// returns a retryable [`ManagedError::InvalidState`] carrying `cause`; on
    /// rollback failure returns the explicit manual-recovery error naming the
    /// exact `tmux rename-session` command to run.
    /// Test: `rename_rolls_back_tmux_when_store_write_fails` in
    /// `super::rename_tests`.
    fn rollback_rename(&self, undo: &Rollback<'_>, cause: &str) -> ManagedError {
        let Rollback {
            session_id,
            new_name,
            old_name,
            id,
        } = *undo;
        match self.tmux.rename_session_id(new_name, session_id, old_name) {
            Ok(()) => ManagedError::InvalidState(
                id.to_string(),
                format!("rename aborted and rolled back ({cause}) — retry the rename"),
            ),
            Err(rollback) => ManagedError::InvalidState(
                id.to_string(),
                format!(
                    "rename half-applied: tmux is now '{new_name}' but the store still \
                     records '{old_name}', and the rollback failed ({rollback}) — manually \
                     run `tmux rename-session -t {} {old_name}` (cause: {cause})",
                    crate::core::tmux::shell_exact_session_target(session_id)
                ),
            ),
        }
    }
}

/// One lock per record id that has a rename running or waiting (#9101).
static RENAME_LOCKS: LazyLock<Mutex<HashMap<ManagedSessionId, Arc<tokio::sync::Mutex<()>>>>> =
    LazyLock::new(Mutex::default);

/// A held turn to rename one record (#9101).
///
/// Why: a live rename addresses tmux by `$N` id, so two renames of one record
/// both reach tmux; unserialized, the loser's rollback desyncs tmux and the
/// record. Keyed by id, so renames of different records never wait.
/// What: [`Self::take`] waits for the id's lock; drop releases it and removes
/// the map entry once no other rename holds or waits for it. Entries are
/// cloned and pruned only under the map lock, so the count check is exact.
/// Test: `two_concurrent_renames_of_one_live_record_leave_tmux_and_the_record_in_step`.
struct RenameTurn {
    id: ManagedSessionId,
    guard: Option<OwnedMutexGuard<()>>,
}

impl RenameTurn {
    async fn take(id: ManagedSessionId) -> Self {
        let lock = Arc::clone(
            RENAME_LOCKS
                .lock()
                .unwrap_or_else(PoisonError::into_inner)
                .entry(id)
                .or_default(),
        );
        Self {
            id,
            guard: Some(lock.lock_owned().await),
        }
    }
}

impl Drop for RenameTurn {
    fn drop(&mut self) {
        drop(self.guard.take());
        let mut locks = RENAME_LOCKS.lock().unwrap_or_else(PoisonError::into_inner);
        if locks
            .get(&self.id)
            .is_some_and(|l| Arc::strong_count(l) == 1)
        {
            locks.remove(&self.id);
        }
    }
}

/// The tmux rename [`SessionManager::rollback_rename`] undoes (#9101).
#[derive(Clone, Copy)]
struct Rollback<'a> {
    /// The `$N` id of the renamed session.
    session_id: &'a str,
    new_name: &'a str,
    old_name: &'a str,
    id: &'a ManagedSessionId,
}

/// Whether `a` and `b` name the same tmux session and pane on the same
/// server (#9101): the fields a rename's ownership proof rests on.
fn same_tmux_identity(a: &SessionRecord, b: &SessionRecord) -> bool {
    a.tmux_name == b.tmux_name && a.pane_id == b.pane_id && a.tmux_server == b.tmux_server
}