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
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
//! Stale duplicate-record dedup for `reconcile_on_boot` (#2306).
//!
//! Why: a project can accrue two conflicting non-`Decommissioned` records — one
//! canonical (resolved `workspace_path`) and one adopted record permanently
//! stuck at `cwd`/`workspace_path = /unknown` (adopted before #2163's
//! `get_pane_cwd` resolution existed). `reconcile_on_boot` only resolves
//! `/unknown` when a LIVE pane is discovered; it never revisits a `/unknown`
//! record once its pane is gone, and nothing collapses N records per project,
//! so `tm` keeps re-attaching to the wrong/stale one. #2001/#2004 (zombie
//! reconcile) and #2148 (non-destructive resume) are single-record and do not
//! cover this.
//!
//! **Grouping caveat (post-review, #2306):** `source_id` is derived from the
//! git remote and is per-REPOSITORY
//! ([`super::adopt::derive_source_id_for_record`]), not per-workspace — every
//! concurrent worktree session of one repo shares the same `source_id`. A
//! naive "keep only the newest per group" policy would therefore collapse
//! *legitimate concurrent sessions*, and worse: an in-project worktree record
//! is `workspace_owned = false` BY DESIGN, yet `decommission` still runs
//! `git worktree remove --force` (+ branch delete) for such a path via
//! [`super::decommission::is_session_worktree`] — so a naive policy would
//! destroy real, possibly-uncommitted work. See the eligibility rule below,
//! which is the actual fix for that danger.
//!
//! What: groups non-`Decommissioned` records by `source_id` (fallback: resolved
//! `workspace_path`). Within a quiesced group (no member has a live tmux
//! session — see the live-group guard), a record is only ever a dedup
//! **loser** when it is unresolved-or-dead: [`is_resolved_existing`] is
//! `false` for it (no `workspace_path`, the `/unknown` sentinel, or a path
//! that no longer exists on disk). A record with a resolved, still-existing
//! `workspace_path` is NEVER a loser — regardless of group size or recency —
//! because such records are the concurrent-session case the naive policy got
//! wrong. Consequently:
//! - a group with 2+ resolved-existing members collapses nothing among them;
//!   only its unresolved/dead members (if any) are decommissioned, and only
//!   because a resolved-existing (or live) member supersedes them;
//! - a group with ZERO resolved-existing members (e.g. `/unknown`-only or
//!   all-dead) keeps the most-recently-active/created member and
//!   decommissions the rest — there is nothing on disk to lose in that case.
//!
//! An unresolved/dead loser is additionally rechecked against the SAME
//! [`is_resolved_existing`] predicate immediately before decommission runs
//! (belt-and-suspenders against a TOCTOU race); a loser that fails the
//! recheck is skipped with a `warn!`, never decommissioned.
//!
//! **Liveness is evidence, and absence of it is not.** Every rule above reads
//! "this record's `tmux_name` is not in the live set" as proof the session is
//! gone, and acts on that proof with `decommission` — which is irreversible:
//! it tombstones the record, clears `workspace_path`, and hides the row from
//! every picker view. The live set must therefore come from tmux actually
//! having been asked and answered. See
//! [`SessionManager::observed_live_managed_names`], which refuses rather than
//! reporting an empty set it cannot stand behind.
//!
//! Test: `plan_dedup_*`, `is_resolved_existing_*`, and `reconcile_dedup_*` in
//! `dedup_tests.rs`; the refusal contract in `dedup_refuses_*`.

use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

use tracing::{info, warn};

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

impl SessionManager {
    /// Live managed tmux session names, or an error when tmux could not be
    /// observed at all.
    ///
    /// Why: the two callers both read ABSENCE from this set as proof a session
    /// is gone, and both act on that proof. `dedup_stale_duplicates`
    /// `decommission`s; `reconcile_on_boot` marks the record `Stopped` and,
    /// under `auto_resume`, queues it for a relaunch it does not need. So an
    /// empty set means "every candidate is dead". Two paths used to hand that
    /// empty set over without tmux ever having answered. First,
    /// `list_sessions()` returning an error, swallowed by
    /// `unwrap_or_default()` in dedup and by `unwrap_or_else(warn, Vec::new)`
    /// in reconcile. Second, and the one no error handling could have caught:
    /// [`super::real_tmux::NoopTmuxDriver`] — installed whenever
    /// `RealTmuxDriver::discover()` fails, which includes tmux being off PATH
    /// AND the #5784 host-state gate refusing tmux access on a reassigned
    /// `$HOME` — answered `Ok(vec![])`, a value indistinguishable from a host
    /// genuinely running zero sessions. Either path decommissions a session
    /// the operator is attached to. A liveness check that cannot see tmux is
    /// not proof of death.
    /// What: runs the #3823/#3886 `ensure_server_up` guard first, so a cold
    /// socket is STARTED rather than misread as empty; then propagates
    /// `list_sessions()`'s error instead of defaulting it away; then filters
    /// to managed names. Every way of failing to observe tmux reaches the
    /// caller as `Err`. Each caller decides what to skip; neither may invent
    /// an empty set.
    /// Test: `dedup_refuses_when_list_sessions_fails`,
    /// `dedup_refuses_when_the_tmux_server_cannot_be_started`,
    /// `dedup_refuses_on_the_noop_driver_rather_than_reading_zero_as_dead`,
    /// `reconcile_refuses_to_stop_sessions_when_tmux_cannot_be_observed`.
    pub(super) fn observed_live_managed_names(&self) -> Result<HashSet<String>, ManagedError> {
        live_managed_names_of(self.tmux.as_ref())
    }
}

/// [`SessionManager::observed_live_managed_names`] against a bare driver.
///
/// Why it takes the driver rather than `&self` (#7965): the probe is two
/// synchronous tmux subprocesses, and
/// [`SessionManager::workspace_claims`](super::SessionManager::workspace_claims)
/// must run it on the blocking pool under a timeout instead of on whatever thread
/// called it. That needs an owned `Arc<dyn ManagedTmuxDriver>` and no borrow of
/// the manager.
/// What: the same body, unchanged — the `ensure_server_up` guard, the propagated
/// `list_sessions` error, the managed-name filter.
/// Test: as [`SessionManager::observed_live_managed_names`], plus
/// `a_slow_tmux_probe_is_bounded_and_leaves_every_claim_live`.
pub(super) fn live_managed_names_of(
    tmux: &dyn super::ManagedTmuxDriver,
) -> Result<HashSet<String>, ManagedError> {
    // #5856: refuse, never assume — see `observed_live_managed_names`'s doc. The
    // guard mirrors `resolve_session_name`'s (#3886): it belongs next to the
    // `list-sessions` probe it protects, not on the callers.
    tmux.ensure_server_up()?;
    Ok(tmux
        .list_sessions()?
        .into_iter()
        .filter(|n| crate::core::names::is_managed_session_name(n))
        .collect())
}

impl SessionManager {
    /// Collapse stale duplicate session records per project (#2306).
    ///
    /// Why: see the module docs — quiesced projects with a canonical record AND
    /// a stale `/unknown` adopted record must converge on the canonical one so
    /// resume/re-attach stops targeting the dead record, WITHOUT ever touching
    /// a resolved, still-existing workspace (the review-fixed danger: naively
    /// collapsing by `source_id` alone would destroy legitimate concurrent
    /// worktree sessions of one repo).
    /// What: re-derives the live managed-tmux name set, reads all records,
    /// plans the losers via [`plan_dedup`] (which already excludes every
    /// resolved-existing record categorically), logs the full plan at `info`
    /// level, removes any planned loser from `to_resume` (never auto-resume a
    /// record about to be decommissioned), then for each loser: re-fetches it
    /// and reruns [`is_resolved_existing`] as a belt-and-suspenders recheck
    /// (skips + `warn!`s if the path has reappeared since planning) before
    /// decommissioning through the standard `decommission` path. Decommission
    /// failures are logged, not fatal. Returns the ids actually decommissioned.
    ///
    /// When tmux cannot be observed at all, the whole pass is skipped and no
    /// record is decommissioned — see
    /// [`Self::observed_live_managed_names`]. The skip returns `Ok(vec![])`
    /// rather than an error so `reconcile_on_boot`, which propagates this
    /// call with `?`, still runs its deploy-validate and auto-resume tail.
    /// Test: `reconcile_dedup_collapses_stopped_duplicates` and siblings;
    /// the refusal in `dedup_refuses_when_list_sessions_fails` and siblings.
    pub(crate) async fn dedup_stale_duplicates(
        &self,
        to_resume: &mut Vec<ManagedSessionId>,
    ) -> Result<Vec<ManagedSessionId>, ManagedError> {
        // #5856: an unobservable tmux is not an empty tmux. Skip the pass.
        let live_names: HashSet<String> = match self.observed_live_managed_names() {
            Ok(names) => names,
            Err(e) => {
                warn!(
                    "dedup: tmux liveness could not be observed ({e}); skipping this pass — \
                     no record is decommissioned without positive evidence its session is gone"
                );
                return Ok(Vec::new());
            }
        };
        let records = self.store.write().await.all().await?;
        let mut losers = plan_dedup(&records, &live_names);

        // #3396: also collapse EXACT-workspace_path duplicates — two+
        // non-Decommissioned records resolving to the literal SAME on-disk
        // worktree. `plan_dedup` above deliberately never touches this shape
        // (its `source_id` grouping is per-repository, and a
        // resolved-existing member is categorically immune there — both
        // correct for the LEGITIMATE concurrent-worktree case, which is why
        // the #3396 duplicate persisted across every prior dedup pass). See
        // `plan_workspace_duplicates`'s doc for why "same literal path" is
        // safe to treat differently. Track which ids came from THIS mechanism
        // so the belt-and-suspenders recheck below applies the right predicate
        // to each.
        let workspace_dup_losers: HashSet<ManagedSessionId> =
            plan_workspace_duplicates(&records, &live_names)
                .into_iter()
                .collect();
        for id in &workspace_dup_losers {
            if !losers.contains(id) {
                losers.push(*id);
            }
        }

        if losers.is_empty() {
            return Ok(losers);
        }

        info!(
            count = losers.len(),
            ids = ?losers.iter().map(ManagedSessionId::to_string).collect::<Vec<_>>(),
            "dedup: plan computed stale duplicate(s) to decommission (#2306, #3396)"
        );

        // Never auto-resume a record we are about to decommission.
        to_resume.retain(|id| !losers.contains(id));

        let mut decommissioned = Vec::new();
        for id in &losers {
            // Belt-and-suspenders: re-fetch immediately before acting, guarding
            // a TOCTOU race between the plan snapshot above and this
            // decommission call.
            let current = match self.get(id).await {
                Ok(r) => r,
                Err(e) => {
                    warn!(id = %id, "dedup: loser vanished before decommission: {e}");
                    continue;
                }
            };
            if workspace_dup_losers.contains(id) {
                // #3396 recheck: an exact-workspace-duplicate loser is
                // EXPECTED to be resolved-existing (that is the grouping key),
                // so the #2306 predicate below does not apply here. Instead
                // refuse when the record turned out to be SM-owned (deletion
                // would risk `git worktree remove --force`ing real work — see
                // `plan_workspace_duplicates`'s categorical owned-record
                // exclusion, rechecked here against a TOCTOU) or when its OWN
                // tmux session has since gone live (decommission kills the
                // whole tmux session — never do that to a live one).
                if current.workspace_owned {
                    warn!(
                        id = %id,
                        "dedup: #3396 belt-and-suspenders skip — loser is SM-owned; \
                         refusing to decommission"
                    );
                    continue;
                }
                if live_names.contains(&current.tmux_name) {
                    warn!(
                        id = %id,
                        name = %current.tmux_name,
                        "dedup: #3396 belt-and-suspenders skip — loser's tmux session is now \
                         live; refusing to decommission an active session"
                    );
                    continue;
                }
            } else if is_resolved_existing(&current) {
                // Belt-and-suspenders (#2306 review fix): re-run the EXACT
                // safety predicate used during planning immediately before
                // acting. A `plan_dedup` loser is only ever planned when
                // unresolved/dead; if its workspace_path now resolves to an
                // existing directory, refuse to decommission rather than risk
                // deleting live work.
                warn!(
                    id = %id,
                    workspace = ?current.workspace_path,
                    "dedup: belt-and-suspenders skip — loser's workspace_path now \
                     resolves to an existing directory; refusing to decommission (#2306)"
                );
                continue;
            } else {
                // #3764 recheck (parity with the #3396 branch above):
                // `plan_dedup`'s own live-group guard already refuses to
                // select a loser out of a group where ANY member's
                // `tmux_name` is in the `live_names` SNAPSHOT taken at the
                // top of this function — but that snapshot can go stale in
                // the time between the planning pass and this call (a
                // resumed/reattached session, or a rename). Re-probe tmux
                // directly, right here, immediately before the destructive
                // call — never reuse the stale top-of-function snapshot for
                // this check, or it could never catch the race it exists
                // for. An unobservable tmux is refused the same way
                // `observed_live_managed_names` refuses everywhere else: no
                // record is decommissioned without positive evidence its
                // session is gone.
                let live_names = match self.observed_live_managed_names() {
                    Ok(names) => names,
                    Err(e) => {
                        warn!(
                            id = %id,
                            "dedup: could not re-probe tmux liveness before decommission \
                             ({e}); refusing to decommission without positive evidence (#3764)"
                        );
                        continue;
                    }
                };
                if live_names.contains(&current.tmux_name) {
                    warn!(
                        id = %id,
                        name = %current.tmux_name,
                        "dedup: belt-and-suspenders skip — loser's tmux session is now live; \
                         refusing to decommission an active session (#3764)"
                    );
                    continue;
                }
            }
            // #3764: dedup's whole job is collapsing two records that name
            // the SAME workspace_path, which the ordinary #3764 guard cannot
            // tell apart from the #1744 cross-session collision it exists to
            // refuse. The tmux-liveness and workspace_owned rechecks above
            // (both branches, now) are the stronger signal dedup already
            // applied — see `decommission_dedup_loser`'s doc.
            match self.decommission_dedup_loser(id).await {
                Ok((rec, _removed)) => {
                    info!(
                        id = %id,
                        name = %rec.tmux_name,
                        "dedup: decommissioned stale duplicate session record (#2306, #3396)"
                    );
                    decommissioned.push(*id);
                }
                Err(e) => warn!(id = %id, "dedup: decommission of stale duplicate failed: {e}"),
            }
        }
        Ok(decommissioned)
    }
}

/// Grouping key for a record, or `None` if it cannot be grouped.
///
/// Why: two records for the SAME project must land in the same group so a
/// quiesced duplicate can be collapsed; a record with neither a `source_id`
/// nor a `workspace_path` (a bare `/unknown` with no provenance) is not safely
/// attributable to any project and is therefore left alone. NOTE: `source_id`
/// is per-repository, not per-workspace (see module docs) — the safety
/// boundary against over-grouping is [`is_resolved_existing`] at selection
/// time, not this key.
/// What: prefers `source_id`; falls back to the `workspace_path` string. The
/// key is namespaced (`src:` / `ws:`) so a source id can never collide with a
/// path.
/// Test: covered indirectly by `plan_dedup_distinct_source_ids_untouched` and
/// `plan_dedup_unknown_only_group_keeps_most_recent`.
fn group_key(record: &SessionRecord) -> Option<String> {
    if let Some(sid) = &record.source_id {
        return Some(format!("src:{sid}"));
    }
    record
        .workspace_path
        .as_ref()
        .map(|ws| format!("ws:{}", ws.display()))
}

/// True when `record.workspace_path` is a resolved, real, currently-existing
/// directory on disk.
///
/// Why (#2306 review fix): this is THE safety boundary for dedup. A record
/// that passes this check represents a legitimate on-disk workspace — which
/// may be one of several concurrent sessions of the SAME repo, since
/// `source_id` groups by repository, not by workspace. Such a record must
/// never be treated as a stale duplicate, no matter the group size or its
/// recency relative to siblings — an in-project worktree record is
/// `workspace_owned = false` by design, so the `workspace_owned` flag alone
/// cannot distinguish "safe to decommission" from "real work that would be
/// `git worktree remove --force`'d". Only a record that FAILS this check (no
/// `workspace_path`, the literal `/unknown` sentinel, or a path that no
/// longer exists — e.g. already removed by other means) is eligible to be a
/// dedup loser.
/// What: `true` iff `workspace_path` is `Some(p)`, `p != Path::new("/unknown")`,
/// and `p.exists()`.
/// Test: `is_resolved_existing_true_for_real_dir`,
/// `is_resolved_existing_false_for_missing_dir`,
/// `is_resolved_existing_false_for_none_and_unknown_sentinel`.
pub(crate) fn is_resolved_existing(record: &SessionRecord) -> bool {
    match &record.workspace_path {
        // #6118: one spelling of the sentinel, shared with every other reader.
        Some(p) => p.as_path() != Path::new(super::record::UNRESOLVED_PATH_SENTINEL) && p.exists(),
        None => false,
    }
}

/// Order two records by recency only (greater = more recently active/created).
///
/// Why: used ONLY to break a tie among unresolved/dead candidates in a group
/// with zero resolved-existing members — there is nothing on disk to lose in
/// that case, so "most recently touched" is a reasonable survivor heuristic.
/// What: ranks by `last_activity_at` (`None` sorts earliest), then
/// `created_at`.
/// Test: `plan_dedup_unknown_only_group_keeps_most_recent`.
fn by_recency(a: &SessionRecord, b: &SessionRecord) -> Ordering {
    a.last_activity_at
        .cmp(&b.last_activity_at)
        .then(a.created_at.cmp(&b.created_at))
}

/// Plan which records to decommission to dedup stale duplicates (#2306).
///
/// Why: the decision must be a pure function of the current record set, disk
/// state (via [`is_resolved_existing`]'s `exists()` check), and the live-tmux
/// name set so it is trivially unit-testable without a real store or tmux
/// driver (the #1790 no-real-tmux guard).
/// What: groups non-`Decommissioned` records via [`group_key`]; skips any
/// group of size <2 or with a live-tmux member. For the remaining (quiesced,
/// size>1) groups, partitions members into resolved-existing vs.
/// unresolved/dead via [`is_resolved_existing`]. If ANY member is
/// resolved-existing, every unresolved/dead member is a loser (the
/// resolved-existing members are NEVER touched, even if there are several).
/// If NONE are resolved-existing, the most-recent member (by [`by_recency`])
/// survives and every other member is a loser.
/// Test: the `plan_dedup_*` tests in `dedup_tests.rs`.
pub(crate) fn plan_dedup(
    records: &[SessionRecord],
    live_names: &HashSet<String>,
) -> Vec<ManagedSessionId> {
    let mut groups: HashMap<String, Vec<&SessionRecord>> = HashMap::new();
    for record in records {
        if matches!(record.state, ManagedSessionState::Decommissioned) {
            continue;
        }
        if let Some(key) = group_key(record) {
            groups.entry(key).or_default().push(record);
        }
    }

    let mut losers = Vec::new();
    for members in groups.values() {
        if members.len() < 2 {
            continue;
        }
        // Live-group guard: if ANY member has a live tmux session, the live one
        // is authoritative — never touch a group that is not fully quiesced.
        if members.iter().any(|r| live_names.contains(&r.tmux_name)) {
            continue;
        }

        // Safety partition (#2306 review fix): a resolved-existing member can
        // NEVER be a loser. Only unresolved/dead members are candidates.
        let (resolved_existing, candidates): (Vec<&SessionRecord>, Vec<&SessionRecord>) = members
            .iter()
            .copied()
            .partition(|r| is_resolved_existing(r));

        if resolved_existing.is_empty() {
            // Nothing in this group is resolved-existing (an /unknown-only or
            // all-dead group): nothing on disk to lose. Keep the most
            // recently active/created candidate, decommission the rest.
            if let Some(survivor) = candidates.iter().copied().max_by(|a, b| by_recency(a, b)) {
                losers.extend(
                    candidates
                        .iter()
                        .filter(|r| r.id != survivor.id)
                        .map(|r| r.id),
                );
            }
        } else {
            // At least one resolved-existing (or live-checked-clean) member
            // supersedes every unresolved/dead candidate. The
            // resolved-existing members themselves are left untouched — even
            // if there are 2+ of them (legitimate concurrent sessions).
            losers.extend(candidates.iter().map(|r| r.id));
        }
    }
    losers
}

/// Plan losers among records that share the IDENTICAL, resolved-existing
/// `workspace_path` (#3396).
///
/// Why: [`plan_dedup`]'s `source_id` grouping is per-REPOSITORY by design
/// (see the module docs) — it deliberately never touches two
/// resolved-existing records with DIFFERENT `workspace_path`s, because that
/// shape is the legitimate concurrent-worktree-sessions case, and it treats
/// ANY resolved-existing member as categorically immune regardless of group
/// size. But two records pointing at the exact SAME `workspace_path` are
/// never that: a single worktree directory hosts at most one live managed
/// identity, so a group of 2+ non-`Decommissioned` records sharing one
/// resolved-existing path is ALWAYS a duplicate-identity bug (#3396 — e.g. a
/// live tmux session renamed/replaced out from under its record, then
/// re-discovered and adopted under a different name against the same cwd by
/// [`super::reconcile::SessionManager::reconcile_on_boot`]'s external-adopt
/// loop, before that loop's own #3396 duplicate-prevention check existed).
/// `plan_dedup`'s live-group guard ("skip the WHOLE group if ANY member is
/// live") and its resolved-existing categorical immunity both, correctly,
/// refuse to touch this shape — which is exactly why a #3396 stale duplicate
/// persists across every `plan_dedup`-only pass. This function is the
/// narrowly-scoped exact-path counterpart: it groups by canonicalized
/// `workspace_path` alone (ignoring `source_id`), and — because "same
/// directory" is unambiguous where "same repository" is not — is allowed to
/// pick a survivor even when one member is currently live.
/// What: groups non-`Decommissioned`, resolved-existing (per
/// [`is_resolved_existing`]) records by canonicalized `workspace_path`, then
/// for each group of 2+:
/// - A `workspace_owned = true` member (the SM provisioned this directory
///   itself, e.g. via clone) is NEVER a loser — categorical protection
///   against ever risking a real, SM-owned repository, mirroring
///   [`is_resolved_existing`]'s role in `plan_dedup`. Two-or-more owned
///   members sharing one path should structurally never happen; rather than
///   guess, that shape leaves the WHOLE group untouched.
/// - Exactly one owned member: it is the automatic survivor, and every
///   `workspace_owned = false` sibling whose `tmux_name` is currently DEAD is
///   a loser (a live one is left alone too — never decommission a currently
///   active session).
/// - Zero owned members: the survivor is chosen among the unowned members —
///   if exactly one has a live `tmux_name` it survives; if none are live, the
///   most recently active/created one survives (mirrors `plan_dedup`'s
///   /unknown tie-break); if two or more are simultaneously live, the group
///   is left untouched — two genuinely-live panes pointed at the same
///   directory is ambiguous enough that guessing a survivor risks tearing
///   down a real session, so this defers to the operator rather than picking
///   one.
///
/// A loser selected here therefore always has both `workspace_owned = false`
/// (decommission never deletes the directory) and a currently-DEAD
/// `tmux_name` (decommission never kills a live tmux session).
/// Test: the `plan_workspace_duplicates_*` tests in `dedup_tests.rs`.
pub(crate) fn plan_workspace_duplicates(
    records: &[SessionRecord],
    live_names: &HashSet<String>,
) -> Vec<ManagedSessionId> {
    let mut groups: HashMap<PathBuf, Vec<&SessionRecord>> = HashMap::new();
    for record in records {
        if matches!(record.state, ManagedSessionState::Decommissioned) {
            continue;
        }
        if !is_resolved_existing(record) {
            continue;
        }
        // is_resolved_existing() only returns true when workspace_path is
        // Some, so this is always populated for a grouped record.
        let Some(path) = record.workspace_path.as_ref() else {
            continue;
        };
        let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.clone());
        groups.entry(canon).or_default().push(record);
    }

    let mut losers = Vec::new();
    for members in groups.values() {
        if members.len() < 2 {
            continue;
        }
        let (owned, unowned): (Vec<&SessionRecord>, Vec<&SessionRecord>) =
            members.iter().copied().partition(|r| r.workspace_owned);

        if owned.len() >= 2 {
            // 2+ SM-owned records for one literal path should never happen;
            // do not guess which is "real" — leave the whole group alone.
            continue;
        }
        if owned.len() == 1 {
            // The owned record is the permanent survivor. Every unowned
            // sibling that is NOT currently live is a duplicate of it.
            losers.extend(
                unowned
                    .iter()
                    .filter(|r| !live_names.contains(&r.tmux_name))
                    .map(|r| r.id),
            );
            continue;
        }

        // owned.is_empty(): pick a survivor among the unowned members.
        if unowned.len() < 2 {
            continue;
        }
        let live_candidates: Vec<&SessionRecord> = unowned
            .iter()
            .copied()
            .filter(|r| live_names.contains(&r.tmux_name))
            .collect();
        match live_candidates.len() {
            0 => {
                if let Some(survivor) = unowned.iter().copied().max_by(|a, b| by_recency(a, b)) {
                    losers.extend(unowned.iter().filter(|r| r.id != survivor.id).map(|r| r.id));
                }
            }
            1 => {
                let survivor_id = live_candidates[0].id;
                losers.extend(unowned.iter().filter(|r| r.id != survivor_id).map(|r| r.id));
            }
            _ => {
                // 2+ simultaneously-live candidates for one directory: leave
                // the whole group untouched (see doc above).
            }
        }
    }
    losers
}