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
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
//! Daemon lifecycle command handlers: start, stop, restart, status probes.
//!
//! Why: daemon lifecycle operations (start/stop/restart, PID discovery,
//! signal helpers, status printing) form a coherent group that benefits from
//! its own file. The heavier "boot and serve" side (`run_daemon` and its
//! private helpers) lives in `daemon_run.rs` to keep both files under the
//! 500-line cap.
//! What: `print_status`, `daemon_healthy`, `start`, `restart`, `stop_daemon`,
//! `cleanup_lock_file`, `find_daemon_pids`, `send_signal`, `pid_alive`.
//! Re-exports `run_daemon` from the sibling `daemon_run` module.
//! Test: `cli_parses_daemon_*` parse tests; the bind/serve and spawn/wait
//! paths are exercised by the daemon e2e suite.

#[path = "daemon_run.rs"]
mod daemon_run;
pub(crate) use daemon_run::run_daemon;

use serde::Deserialize;

use crate::formatters::session::short_id;
use crate::types::SessionRow;

/// Find the git repository root for a working directory path.
///
/// Why: grouping sessions by git root avoids showing one row per session for the
/// same project and makes large fleets more readable (#1839 Fix 5).
/// What: runs `git -C <workdir> rev-parse --show-toplevel`; returns the git root
/// as a `String` on success, or the original `workdir` when not inside a repo.
/// Test: `group_sessions_by_git_root_no_git` verifies the fallback.
pub(crate) fn git_root_for(workdir: &str) -> String {
    let out = std::process::Command::new("git")
        .arg("-C")
        .arg(workdir)
        .args(["rev-parse", "--show-toplevel"])
        .output()
        .ok()
        .filter(|o| o.status.success());
    match out {
        Some(o) => String::from_utf8_lossy(&o.stdout).trim().to_string(),
        None => workdir.to_string(),
    }
}

/// Group sessions by their git repository root, with the CWD repo listed first.
///
/// Why: `tm status` previously dumped one line per session with no grouping,
/// making large fleets difficult to scan (#1839). Grouping by git root lets
/// the operator immediately see which project each session belongs to.
/// What: for each session derives its git root via `git_root_for`, memoizing
/// results in a `HashMap` keyed by `workdir` so each unique working directory
/// spawns at most one `git rev-parse` subprocess (O(unique_dirs) instead of
/// O(n) subprocess spawns for n sessions). Builds a `Vec<(root, sessions)>`
/// sorted by root path, with the CWD's repo first.
/// Sessions whose workdir is not inside a git repo use `workdir` as the key.
/// Test: `group_sessions_by_git_root_groups_correctly`.
pub(crate) fn group_by_git_root<'a>(
    sessions: &'a [SessionRow],
    cwd_root: &str,
) -> Vec<(String, Vec<&'a SessionRow>)> {
    use std::collections::{BTreeMap, HashMap};
    // Memoize git_root_for: many sessions may share the same workdir, but even
    // when they don't, adjacent sessions often live in the same repo. One
    // subprocess per unique workdir instead of one per session row.
    let mut cache: HashMap<&str, String> = HashMap::new();
    let mut map: BTreeMap<String, Vec<&SessionRow>> = BTreeMap::new();
    for s in sessions {
        let key = cache
            .entry(s.workdir.as_str())
            .or_insert_with(|| git_root_for(&s.workdir))
            .clone();
        map.entry(key).or_default().push(s);
    }
    let mut groups: Vec<(String, Vec<&SessionRow>)> = map.into_iter().collect();
    // Move the CWD's repo to the front for quick orientation.
    if let Some(pos) = groups.iter().position(|(k, _)| k == cwd_root) {
        let cwd_group = groups.remove(pos);
        groups.insert(0, cwd_group);
    }
    groups
}

/// Print the daemon health line, session listing, and Telegram-bot note.
///
/// Why: `status` and `start` must show identical state; sharing one printer
/// keeps the two outputs from drifting and adds the Telegram note in one place.
/// What: prints `daemon: ok`, sessions grouped by git repository root (CWD's
/// repo first, with `*` annotation), then `Telegram bot active` when a bot
/// token is resolvable from the environment or a local env file.
/// Test: covered indirectly by running `tm status` / `tm start` against a live
/// daemon; Telegram token resolution is tested in `trusty-mpm-telegram`.
pub(crate) async fn print_status(client: &reqwest::Client, url: &str) -> anyhow::Result<()> {
    println!("daemon: ok");
    print_sessions(&trusty_mpm::client::DaemonClient::with_client(
        client.clone(),
        url,
    ))
    .await
}

/// Print the session listing and Telegram-bot note, with no health line.
///
/// Why (#8025): `tm status` now derives the daemon line from the SAME `/health`
/// probe `tm doctor` uses, so it must print the listing without a second,
/// possibly contradictory health verdict of its own. Splitting the listing out
/// leaves `print_status` — and therefore `tm start` — byte-identical.
/// What: `GET /sessions`, grouped by git repository root (CWD's repo first,
/// with a `*` annotation), then `Telegram bot active` when a bot token is
/// resolvable. `Err` on any transport or non-2xx, which the caller reports as
/// an unavailable LISTING rather than as an unreachable daemon.
/// Test: `status_reports_the_live_daemon_when_the_listing_fails` in
/// `status_daemon_tests.rs` drives the failing-listing half.
pub(crate) async fn print_sessions(
    daemon: &trusty_mpm::client::DaemonClient,
) -> anyhow::Result<()> {
    #[derive(Deserialize)]
    struct Body {
        sessions: Vec<SessionRow>,
    }
    let body: Body = daemon
        .get("/sessions")
        .send()
        .await?
        .error_for_status()?
        .json()
        .await?;

    // Determine the CWD's git root for highlighting.
    let cwd = std::env::current_dir()
        .map(|p| p.to_string_lossy().into_owned())
        .unwrap_or_default();
    let cwd_root = git_root_for(&cwd);

    let groups = group_by_git_root(&body.sessions, &cwd_root);
    for (root, sessions) in &groups {
        let marker = if root == &cwd_root { " *" } else { "" };
        println!("---- {} ({} session(s)){marker} ----", root, sessions.len());
        for s in sessions {
            let status = s.status.as_str().unwrap_or("unknown");
            println!(
                "  {} {} {} ({} delegations)",
                short_id(&s.id),
                status,
                s.workdir,
                s.active_delegations
            );
        }
    }

    if trusty_mpm::telegram::resolve_token("TELEGRAM_BOT_TOKEN").is_some() {
        println!("Telegram bot active");
    }
    Ok(())
}

/// Probe the daemon's `/health` endpoint for liveness.
///
/// Why: both `start` (to decide whether to spawn) and the post-spawn wait loop
/// need a single yes/no liveness check; factoring it out keeps the two call
/// sites identical and the intent obvious.
/// What: issues `GET {url}/health`, returning `true` only on a 2xx response and
/// `false` on any transport error or non-success status.
/// Test: covered indirectly by running `tm start` against a live/dead daemon;
/// the logic mirrors the probe already used by `status`.
pub(crate) async fn daemon_healthy(client: &reqwest::Client, url: &str) -> bool {
    // Verify /health is 200 AND /sessions is 200. Code-intelligence on the same
    // port returns 200 for /health but 404 for /sessions, so checking both
    // discriminates our daemon from other HTTP servers on the same port.
    let health_ok = match client.get(format!("{url}/health")).send().await {
        Ok(r) => r.status().is_success(),
        Err(_) => return false,
    };
    if !health_ok {
        return false;
    }
    match client.get(format!("{url}/sessions")).send().await {
        Ok(r) => r.status().is_success(),
        Err(_) => false,
    }
}

/// The line `tm start` prints when the daemon is already up.
///
/// Why (#6869): this printed `Daemon already running on <resolved url>`, the
/// third site still putting the daemon's address in front of the operator.
/// Rendering it through a function that TAKES the URL and puts none of it in
/// the result is what makes "no address survives into the line" assertable for
/// any address the discovery chain can produce — a later `format!("… on
/// {check_url}")` fails the test rather than shipping.
/// What: returns [`daemon_run::ALREADY_RUNNING_NOTICE`] verbatim, so `tm start`
/// and `tm daemon` say the same thing from one constant. The caller logs the
/// URL at debug level.
/// Test: `start_already_running_line_names_no_address`.
fn already_running_line(_check_url: &str) -> String {
    daemon_run::ALREADY_RUNNING_NOTICE.to_string()
}

/// `start` subcommand — ensure the daemon is running, then show status.
///
/// Why: operators want one command that is safe to run repeatedly — it brings
/// the daemon up if it is down and is a no-op (just status) if it is already
/// up, so `tm start` can sit in shell profiles and setup scripts on a host where
/// launchd does NOT own the daemon. Where launchd DOES own it, `tm start` is the
/// wrong verb and now says so rather than racing launchd (#4230), so it is no
/// longer appropriate in a shell profile on such a host — use `launchctl` there.
/// What: probes `/health`; if healthy, prints [`already_running_line`] plus the
/// same listing as `tm status`. If not, opens `~/.trusty-mpm/daemon.log`, spawns
/// `tm daemon` detached with stdout/stderr appended to that log, polls `/health`
/// for up to 5 seconds, then prints "Starting daemon... done" and the status.
/// Neither branch names an address any more (#6869); both log the resolved URL
/// at debug level instead.
///
/// #4230: refuses to spawn at all when a trusty-mpm launchd unit is registered.
/// This was the ONE client-side daemon-spawn path with no launchd awareness —
/// the stdio bridge has had `no_spawn` since #2486 and `guided_autostart` has
/// nudged launchd since #1900 — and it is the path that produced the #4230
/// orphan.
/// Test: `cli_parses_start` covers parsing; the refusal decision and message are
/// covered by `launchd_probe`'s `daemon_label_*` and `cli_spawn_refusal_*` tests;
/// the spawn/wait path is exercised by running `tm start` against a clean
/// environment.
/// The URL `tm start` health-checks: the lock's, else the one it was given.
///
/// Why (#9556): in a sandbox with no lock the resolver refuses; that refusal
/// is not a lock, so an explicit `--url` naming a live sandbox daemon must be
/// the one checked.
/// What: `lock` when it is `Ok` and not [`trusty_mpm::core::DEFAULT_DAEMON_URL`]
/// (or `url` is that default too), else `url`.
/// Test: `start_checks_the_given_url_when_the_resolver_refuses`.
fn start_check_url(lock: Result<String, trusty_mpm::core::DaemonUrlError>, url: &str) -> String {
    use trusty_mpm::core::DEFAULT_DAEMON_URL;
    match lock {
        // #9556: only a real lock counts; the isolated refusal is not one.
        Ok(lock_url) if lock_url != DEFAULT_DAEMON_URL || url == DEFAULT_DAEMON_URL => lock_url,
        _ => url.to_string(),
    }
}

pub(crate) async fn start(client: &reqwest::Client, url: &str) -> anyhow::Result<()> {
    // Prefer the lock file URL — it's the address our daemon actually bound to,
    // not whatever default URL the CLI was given (which may point at a different
    // process on the same port, e.g. code-intelligence on :7880).
    let check_url = start_check_url(trusty_mpm::core::try_resolve_daemon_url(None), url);
    if daemon_healthy(client, &check_url).await {
        // #6869: the resolved URL goes to the log, not to the operator.
        tracing::debug!("daemon already running at {check_url}");
        println!("{}", already_running_line(&check_url));
        return print_status(client, &check_url).await;
    }

    // #4230: nothing is serving, but launchd may still OWN the daemon. Spawning
    // a detached `tm daemon` here is exactly how the #4230 orphan was created —
    // it seized :7880 for two days while `com.trusty.mpm` reported `not running`,
    // so a fresh signed install verified green against a stale 1.0.2 image. Bail
    // out with the launchctl verb instead of racing launchd. The label is
    // resolved rather than assumed so the recipe names a unit that exists here.
    //
    // #5623: a `LaunchAgents` that could not be read also refuses here — it used
    // to read as "no unit registered" and wave the spawn straight through.
    if let Some(msg) = crate::commands::launchd_probe::cli_spawn_refusal() {
        anyhow::bail!(msg);
    }
    // #9556: a sandbox never spawns a plain `tm daemon` on the host default.
    trusty_mpm::core::refuse_daemon_spawn_when_isolated()?;

    // Resolve the log file under `~/.trusty-mpm/`, creating the dir if absent.
    let root = trusty_mpm::core::paths::FrameworkPaths::default().root;
    std::fs::create_dir_all(&root)?;
    let log_path = root.join("daemon.log");
    let stdout = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&log_path)?;
    let stderr = stdout.try_clone()?;

    // Spawn `tm daemon` detached, pointed at our own binary so the spawned
    // daemon is always the same build as this CLI. We do NOT pass TRUSTY_MPM_ADDR
    // — the daemon picks its own port (falling back to ephemeral if needed) and
    // records the actual address in the lock file. We discover it from there.
    let lock_path = trusty_mpm::core::lock_file_path();
    // Remove any stale lock file before spawning so we can detect the new write.
    let _ = std::fs::remove_file(&lock_path);
    let exe = std::env::current_exe()?;
    // Set a stable cwd so the spawned daemon never inherits a deleted directory.
    let stable_dir = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/"));
    let mut cmd = std::process::Command::new(&exe);
    cmd.arg("daemon")
        .current_dir(&stable_dir)
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::from(stdout))
        .stderr(std::process::Stdio::from(stderr));
    // #8783: own session, so a group kill aimed at this CLI spares the daemon.
    trusty_common::daemon_guard::start_in_new_session(&mut cmd).spawn()?;

    // Poll the lock file for up to 5 seconds. The daemon writes it as soon as
    // it has a bound address — use that URL for the health check.
    print!("Starting daemon... ");
    use std::io::Write as _;
    std::io::stdout().flush().ok();
    let mut healthy = false;
    // Use the lock-file URL if available, fall back to the cli url.
    let mut actual_url = url.to_string();
    for _ in 0..10 {
        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
        // Re-resolve: once the lock file appears it wins over the default.
        actual_url = trusty_mpm::core::resolve_daemon_url(None);
        if daemon_healthy(client, &actual_url).await {
            healthy = true;
            break;
        }
    }
    if healthy {
        println!("done");
        // #6869: the bound address is a discovery detail — clients read it
        // from the lock file, so it goes to the log rather than to stdout.
        tracing::debug!("daemon listening on {actual_url}");
    } else {
        println!("failed");
        println!(
            "daemon did not become healthy within 5s; see {}",
            log_path.display()
        );
        return Ok(());
    }

    print_status(client, &actual_url).await
}

/// `restart` subcommand — stop the running daemon, then start a new one.
///
/// Why: a restart cycle is needed after config changes; running `stop` then
/// `start` manually has a gap where the daemon is unreachable.
/// What: if the daemon is healthy, sends SIGTERM (via pkill) and waits up to
/// 3 s for the port to free, then calls `start`.
///
/// #4230: the launchd check happens BEFORE the `pkill`, not after. `start`
/// refuses to spawn when launchd owns the daemon, so checking only there would
/// tear the supervised daemon down and then decline to bring it back — worse
/// than the pre-fix behaviour. `pkill` is also the pre-existing route into the
/// #4230 state: SIGTERM makes the launchd job exit 0, and `KeepAlive
/// {SuccessfulExit: false}` means launchd deliberately does NOT respawn it.
/// Test: `cli_infers_abbreviated_subcommands` covers `restart` parsing (there is
/// no `cli_parses_restart`); the refusal decision and message are covered by
/// `launchd_probe`'s `daemon_label_*` and `cli_spawn_refusal_*` tests; the
/// spawn/wait path is exercised by running `tm restart` against a clean
/// environment.
pub(crate) async fn restart(client: &reqwest::Client, url: &str) -> anyhow::Result<()> {
    // #5623: refuses on an unreadable `LaunchAgents` too — see `start`.
    if let Some(msg) = crate::commands::launchd_probe::cli_spawn_refusal() {
        anyhow::bail!(msg);
    }
    // #9556: refuse before the host-wide `pkill`, not only before the spawn.
    trusty_mpm::core::refuse_daemon_spawn_when_isolated()?;
    if daemon_healthy(client, url).await {
        print!("Stopping daemon... ");
        use std::io::Write as _;
        std::io::stdout().flush().ok();
        // Kill any running tm/trusty-mpm daemon processes.
        std::process::Command::new("pkill")
            .args(["-f", "tm daemon"])
            .status()
            .ok();
        std::process::Command::new("pkill")
            .args(["-f", "trusty-mpm daemon"])
            .status()
            .ok();
        // Wait until the port is free (up to 3 s).
        for _ in 0..6 {
            tokio::time::sleep(std::time::Duration::from_millis(500)).await;
            if !daemon_healthy(client, url).await {
                break;
            }
        }
        println!("done");
    }
    start(client, url).await
}

/// `stop` subcommand — terminate every running trusty-mpm daemon.
///
/// Why: pairs with `start` so operators can shut the daemon down without a
/// full `restart` cycle. Mirrors `trusty-search stop` and `trusty-memory
/// stop` so the three daemons share a single mental model. The daemon
/// writes its address into `~/.trusty-mpm/daemon.lock`, but the lock-file
/// PID can go stale (a SIGKILL leaves it behind), so the source of truth
/// is the live process table.
/// What: walks the process table via `sysinfo` for every `trusty-mpm` or
/// `tm` process whose argv contains `daemon`, sends SIGTERM, polls 5 s for
/// them to exit, then SIGKILLs stragglers. Removes the stale lock file
/// when every targeted process has exited.
///
/// #4230 (review, MEDIUM-2): `stop` is deliberately NOT refused — stopping the
/// daemon is a legitimate thing to ask for — but it IS the last unblocked route
/// into the #4230 precursor state, so it now says so. Its SIGTERM makes a
/// launchd-owned daemon exit 0, and the plist's
/// `KeepAlive {SuccessfulExit: false}` means launchd deliberately does not
/// respawn it. That is exactly how the incident's `com.trusty.mpm` came to report
/// `not running` for four days before anything else went wrong. The warning names
/// the command that brings it back.
/// Test: `cli_parses_stop`; the hint's resolution logic is covered by
/// `launchd_probe`'s `restart_command_*` tests; the spawn/kill path is exercised
/// by running `tm start` followed by `tm stop` against a clean environment.
pub(crate) async fn stop_daemon() -> anyhow::Result<()> {
    use std::time::{Duration, Instant};

    let targets = find_daemon_pids();
    if targets.is_empty() {
        anyhow::bail!("No daemon running");
    }

    println!(
        "Stopping trusty-mpm daemon ({} process(es): {:?})…",
        targets.len(),
        targets
    );

    // #4230: launchd will NOT bring this back on its own — the plist sets
    // `KeepAlive {SuccessfulExit: false}` and a SIGTERM stop exits 0.
    // #5623: the undeterminable arm says so rather than staying silent — silence
    // here reads as "no launchd unit", and the operator walks away from a stopped
    // daemon believing nothing owns it.
    match crate::commands::launchd_probe::daemon_launchd_label() {
        crate::commands::launchd_probe::DaemonLabelProbe::Registered(label) => println!(
            "note: launchd unit `{label}` owns this daemon and will NOT respawn it \
             (KeepAlive.SuccessfulExit=false). Bring it back with `{}` — issue #4230.",
            crate::commands::launchd_probe::daemon_restart_command_for(Some(&label))
        ),
        crate::commands::launchd_probe::DaemonLabelProbe::Undeterminable => println!(
            "note: `~/Library/LaunchAgents` could not be read, so whether a launchd unit \
             owns this daemon is unknown. If one does, it will NOT respawn it \
             (KeepAlive.SuccessfulExit=false) — issue #4230."
        ),
        crate::commands::launchd_probe::DaemonLabelProbe::NotRegistered => {}
    }

    // Phase 1: SIGTERM all targets.
    for pid in &targets {
        let _ = send_signal(*pid, "TERM");
    }

    // Phase 2: poll up to 5 s for every targeted PID to exit.
    let deadline = Instant::now() + Duration::from_secs(5);
    loop {
        tokio::time::sleep(Duration::from_millis(100)).await;
        let any_alive = targets.iter().any(|p| pid_alive(*p));
        if !any_alive {
            println!("Daemon stopped");
            cleanup_lock_file(&targets);
            return Ok(());
        }
        if Instant::now() >= deadline {
            break;
        }
    }

    // Phase 3: SIGKILL anything still alive.
    let stragglers: Vec<u32> = targets.iter().copied().filter(|p| pid_alive(*p)).collect();
    if !stragglers.is_empty() {
        println!(
            "{} process(es) ignored SIGTERM — sending SIGKILL: {:?}",
            stragglers.len(),
            stragglers
        );
        for pid in &stragglers {
            let _ = send_signal(*pid, "KILL");
        }
        tokio::time::sleep(Duration::from_millis(500)).await;
    }

    if targets.iter().any(|p| pid_alive(*p)) {
        println!("Daemon may still be shutting down");
    } else {
        println!("Daemon stopped");
        cleanup_lock_file(&targets);
    }
    Ok(())
}

/// Remove the `~/.trusty-mpm/daemon.lock` left by the daemons we just stopped.
///
/// Why: the daemon writes its lock file on bind and removes it on graceful
/// shutdown, but SIGKILL leaves it behind; the next `tm status` call would
/// then chase a dead address through the discovery timeout.
/// Why `stopped` (#1731): this used to delete whatever file sat at the path.
/// A daemon that started between the SIGTERM and this call owns a valid record,
/// and deleting it strands a live daemon with no lock file.
/// What: removes the record only when it names one of `stopped`, then drops the
/// guided-autostart pidfile.
/// Test: `remove_lock_owned_by_keeps_newer_daemons_record` in
/// `trusty_mpm::core::daemon_identity`; the stop path is covered by the stop
/// integration path.
pub(crate) fn cleanup_lock_file(stopped: &[u32]) {
    trusty_mpm::core::daemon_identity::remove_lock_owned_by(stopped);
    // Also drop the guided-autostart pidfile so a stale PID from a prior raw
    // fallback spawn does not mislead `tm`'s tooling after the daemon stops (#1900).
    let root = trusty_mpm::core::paths::FrameworkPaths::default().root;
    super::guided_autostart::remove_autostart_pidfile(&root);
}

/// Walk the process table and return every trusty-mpm daemon PID.
///
/// Why: `tm stop` needs to find the daemon regardless of which binary alias
/// (`trusty-mpm` or `tm`) was used to launch it. Matching argv on `daemon`
/// filters out short-lived CLI invocations (`tm status`, `tm doctor`) whose
/// process names also match.
/// What: refreshes the process list once WITH argv (#9034: without it `cmd()`
/// is always empty and nothing matched), and keeps every pid that
/// [`super::daemon_pid_identity::classify_process`] calls a daemon — a name in
/// `OWN_BINARY_NAMES` (#4058) with a `daemon` argument. Excludes the current
/// process.
/// Test: `find_daemon_pids_finds_a_tm_daemon_process`.
pub(crate) fn find_daemon_pids() -> Vec<u32> {
    use super::daemon_pid_identity::{PidIdentity, classify_process, name_and_cmd};
    let sys = super::daemon_pid_identity::refresh_with_cmd(sysinfo::ProcessesToUpdate::All);
    let me = std::process::id();
    sys.processes()
        .iter()
        .filter(|(pid, _)| pid.as_u32() != me)
        .filter(|(_, p)| {
            let (name, cmd) = name_and_cmd(p);
            classify_process(&name, &cmd) == PidIdentity::Daemon
        })
        .map(|(pid, _)| pid.as_u32())
        .collect()
}

/// Send a POSIX signal to a PID by shelling out to `/bin/kill`.
///
/// Why: avoid adding a `nix` dependency for the sole purpose of sending
/// SIGTERM / SIGKILL. `kill -SIGNAL pid` is universally available on
/// every Unix the daemon supports (macOS, Linux).
/// What: spawns `kill -<sig> <pid>` and returns an error if the exit
/// status is non-zero.
/// Test: covered indirectly by the stop integration path.
#[cfg(unix)]
pub(crate) fn send_signal(pid: u32, sig: &str) -> std::io::Result<()> {
    let status = std::process::Command::new("kill")
        .arg(format!("-{sig}"))
        .arg(pid.to_string())
        .status()?;
    if !status.success() {
        return Err(std::io::Error::other(format!(
            "kill -{sig} {pid} exited {status}"
        )));
    }
    Ok(())
}

#[cfg(not(unix))]
pub(crate) fn send_signal(_pid: u32, _sig: &str) -> std::io::Result<()> {
    Err(std::io::Error::other(
        "signals unsupported on this platform",
    ))
}

/// Check whether a PID is still alive.
///
/// Why: the SIGTERM-then-SIGKILL poll loop needs a "is this PID alive?" probe.
/// #1731: this spawned a `kill -0` subprocess per PID per poll iteration and
/// was the crate's second answer to the question; both now route through the
/// `kill(pid, 0)` syscall in `core::daemon_identity`.
/// Test: `pid_alive_true_for_self` in `trusty_mpm::core::daemon_identity`.
pub(crate) fn pid_alive(pid: u32) -> bool {
    trusty_mpm::core::daemon_identity::pid_alive(pid)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Why (#9556 critic): an isolated `tm start --url <live sandbox>` with no
    /// lock checked the refusal sentinel's health instead of its own URL.
    /// Test: itself.
    #[test]
    fn start_checks_the_given_url_when_the_resolver_refuses() {
        use trusty_mpm::core::{DEFAULT_DAEMON_URL, DaemonUrlError};
        let sandbox = "http://127.0.0.1:17881";
        assert_eq!(
            start_check_url(Err(DaemonUrlError::NoSandboxDaemon), sandbox),
            sandbox
        );
        // A real lock still wins over a default `url`, as before.
        assert_eq!(
            start_check_url(Ok(sandbox.to_string()), DEFAULT_DAEMON_URL),
            sandbox
        );
        assert_eq!(
            start_check_url(Ok(DEFAULT_DAEMON_URL.to_string()), sandbox),
            sandbox
        );
    }

    /// Why (#6869): `tm start` was the third site still printing the daemon's
    /// address, after the welcome banner and the `tm daemon` startup guard.
    /// What: renders the already-running line for the default port and for a
    /// non-default one — a test pinning only 7880 would still pass against a
    /// hardcoded default — and asserts neither address reaches the operator,
    /// that the line still names the daemon, and that `tm start` and
    /// `tm daemon` render the one shared constant rather than two copies.
    /// Test: this is the test.
    #[test]
    fn start_already_running_line_names_no_address() {
        for url in [
            trusty_mpm::core::DEFAULT_DAEMON_URL,
            "http://127.0.0.1:9911",
        ] {
            let line = already_running_line(url);
            for needle in ["7880", "9911", "127.0.0.1", "http://", ":"] {
                assert!(
                    !line.contains(needle),
                    "`tm start` must not print {needle:?} for {url}: {line}"
                );
            }
            assert!(
                line.contains("trusty-mpm daemon is already running"),
                "the operator must still be told what happened: {line}"
            );
            assert_eq!(
                line,
                daemon_run::ALREADY_RUNNING_NOTICE,
                "`tm start` and `tm daemon` must render the one shared constant"
            );
        }
    }

    fn make_session(workdir: &str) -> SessionRow {
        SessionRow {
            id: serde_json::json!({"0": "00000000-0000-0000-0000-000000000000"}),
            workdir: workdir.to_string(),
            status: serde_json::json!("active"),
            active_delegations: 0,
        }
    }

    #[test]
    fn group_sessions_by_git_root_no_git() {
        // A path with no .git ancestor: the workdir itself is the key.
        let sessions = vec![make_session("/tmp/no-git-dir")];
        let groups = group_by_git_root(&sessions, "/tmp/no-git-dir");
        assert_eq!(groups.len(), 1, "one group for the single session");
        assert_eq!(groups[0].0, "/tmp/no-git-dir");
        assert_eq!(groups[0].1.len(), 1);
    }

    #[test]
    fn group_sessions_by_git_root_cwd_first() {
        // Two sessions in different roots: cwd_root should be listed first.
        let sessions = vec![
            make_session("/tmp/alpha-dir"),
            make_session("/tmp/beta-dir"),
        ];
        // Force the git_root_for fallback by using dirs that don't have .git.
        let groups = group_by_git_root(&sessions, "/tmp/beta-dir");
        // beta-dir is the cwd_root → it should be first.
        assert_eq!(groups[0].0, "/tmp/beta-dir", "cwd_root group must be first");
    }

    #[test]
    fn group_sessions_same_root_together() {
        // Two sessions with the same workdir (same git root) → one group.
        let sessions = vec![make_session("/tmp/same-dir"), make_session("/tmp/same-dir")];
        let groups = group_by_git_root(&sessions, "/tmp/same-dir");
        assert_eq!(groups.len(), 1, "same root → one group");
        assert_eq!(groups[0].1.len(), 2, "two sessions in the group");
    }

    #[test]
    fn git_root_for_nonexistent_path_returns_workdir() {
        let path = "/tmp/definitely-not-a-git-repo-xyzzy-12345";
        let result = git_root_for(path);
        assert_eq!(result, path, "non-git path must fall back to workdir");
    }
}