Skip to main content

wire/
daemon_supervisor.rs

1//! `wire daemon --all-sessions` — multi-session supervisor.
2//!
3//! ## Why
4//!
5//! honey-pine's 2026-06-01 dogfood (#162) surfaced a launchd-vs-session
6//! isolation gap: the `sh.slancha.wire.daemon` launchd unit invokes
7//! `wire daemon --interval 5` with **no cwd context**. With WIRE_HOME
8//! unset, the daemon resolves to the *default* session WIRE_HOME and
9//! silently skips every other initialized session. Operators with
10//! multiple per-project sessions (slancha-mesh, wire, etc.) saw their
11//! shell `wire status` report `running:false` even with the launchd
12//! daemon perfectly alive — same daemon, different state tree.
13//!
14//! Her working remedy was `launchctl bootout` + `nohup wire daemon`
15//! from the project cwd. That works for one session but doesn't scale
16//! to N. The architectural fix is a supervisor that owns the
17//! multi-session orchestration: one supervisor process per launchd
18//! unit, N child `wire daemon --session <name>` processes — each with
19//! its own pinned `WIRE_HOME` and its own pidfile under that session's
20//! state dir. `wire status` from any cwd then sees its session's child
21//! pid and reports truthfully.
22//!
23//! ## Model
24//!
25//! - **Fork-exec, not threads.** Each session's daemon needs its own
26//!   `WIRE_HOME`. We set it via the child process env so the daemon
27//!   code path stays unchanged. Threads would mean global mutable
28//!   `WIRE_HOME` and cross-session races.
29//! - **Idempotent spawn.** Before spawning a child for session S,
30//!   check `daemon_singleton_holder()` on that session's home. If a
31//!   live daemon already exists (operator ran `wire daemon` directly
32//!   in S's cwd, or supervisor restarted and the old child is still
33//!   alive), leave it alone.
34//! - **Reap via polling, not SIGCHLD.** macOS launchd-supervised
35//!   processes already get SIGCHLD overhead; `try_wait` polling on a
36//!   short interval is simpler and bug-free across platforms.
37//! - **Backoff on rapid failure.** A child that exits within 10s of
38//!   spawn doubles its respawn delay (1s → 60s cap). Prevents a broken
39//!   session (corrupt key, missing relay) from fork-bombing.
40//! - **Don't exit on zero sessions.** Sleep and re-poll the registry —
41//!   new sessions get picked up without supervisor restart.
42//! - **Adopt orphaned children on supervisor restart.** When launchd
43//!   relaunches the supervisor, the previous supervisor's children
44//!   keep running (correct: they're still syncing). New supervisor
45//!   sees their pidfiles, skips re-spawning, and lets them keep going
46//!   until their next natural exit (then it spawns a fresh child).
47//!
48//! ## Invariants
49//!
50//! - One supervisor per launchd unit per machine. Singleton guard on
51//!   `sessions_root()/supervisor.pid` (separate from per-session
52//!   daemon pidfiles).
53//! - Child env contains exactly one wire-relevant variable:
54//!   `WIRE_HOME=<session-home>`. Any other inherited WIRE_* vars are
55//!   stripped so the operator's shell config doesn't leak in.
56//! - Per-session daemon code is *unchanged* — supervisor is a pure
57//!   orchestrator.
58
59use std::collections::HashMap;
60use std::path::{Path, PathBuf};
61use std::process::{Child, Command};
62use std::time::{Duration, Instant, SystemTime};
63
64use anyhow::{Context, Result};
65use serde_json::json;
66
67/// How often the supervisor re-reads the session registry. Tradeoff: a
68/// new session created at `wire session new` waits up to this many
69/// seconds before its daemon comes up. 10s strikes a balance — fast
70/// enough that operators don't notice, slow enough that registry
71/// fork-execs don't dominate.
72const REGISTRY_POLL_SECS: u64 = 10;
73
74/// Initial respawn delay after a child exits unexpectedly. Doubles on
75/// each rapid failure (exit within `RAPID_FAIL_WINDOW`) up to
76/// `MAX_BACKOFF`.
77const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
78const MAX_BACKOFF: Duration = Duration::from_secs(60);
79const RAPID_FAIL_WINDOW: Duration = Duration::from_secs(10);
80
81/// Default idle cutoff for registry-unbound sessions. `list_sessions()`
82/// enumerates *every* session home ever minted on the machine — and
83/// because each Claude tab / `wire session new` mints a fresh persona
84/// home, a long-lived box accumulates hundreds (honey-pine's had 147).
85/// Spawning one daemon per home turns `--all-sessions` into a fork
86/// storm. A session is kept regardless of age if it has a registry cwd
87/// binding (operator deliberately bound it) OR holds a real identity
88/// (`config/wire/private.key` — not a husk); an unbound, identity-less
89/// session is only kept if it has been active within this window. Override
90/// via `WIRE_ALL_SESSIONS_MAX_IDLE_DAYS` (0 disables the filter → legacy
91/// spawn-for-all behavior).
92const DEFAULT_MAX_IDLE_DAYS: u64 = 7;
93
94/// Parse the idle cutoff. `None` raw → default; a `0` value → `None`
95/// (no filter, spawn for every session); any other integer → that many
96/// days; unparseable → default. Pure, so it's unit-testable without
97/// mutating process env.
98fn parse_max_idle(raw: Option<&str>) -> Option<Duration> {
99    match raw {
100        Some(v) => {
101            let days: u64 = v.trim().parse().unwrap_or(DEFAULT_MAX_IDLE_DAYS);
102            (days != 0).then(|| Duration::from_secs(days * 86_400))
103        }
104        None => Some(Duration::from_secs(DEFAULT_MAX_IDLE_DAYS * 86_400)),
105    }
106}
107
108/// Read the idle cutoff from the environment. `None` means "no idle
109/// filter" (spawn a daemon for every session — pre-fix behavior),
110/// selected by setting `WIRE_ALL_SESSIONS_MAX_IDLE_DAYS=0`.
111fn max_idle_from_env() -> Option<Duration> {
112    parse_max_idle(
113        std::env::var("WIRE_ALL_SESSIONS_MAX_IDLE_DAYS")
114            .ok()
115            .as_deref(),
116    )
117}
118
119/// Newest mtime among a session home's activity files — the
120/// supervisor's "last actually *synced*" signal. These live under the
121/// session's `state/wire/` subtree (same root the per-session daemon
122/// and `existing_daemon_for_session` use), NOT the home root.
123/// `last_sync.json` is rewritten on every successful daemon relay
124/// cycle; the cursors move on inbox/reactor activity. Returns `None`
125/// for a home that has never synced (a husk).
126///
127/// Deliberately excludes `daemon.pid`: it's written on *spawn*, so
128/// counting it would make eligibility self-perpetuating — the
129/// supervisor spawns a daemon, the pidfile refreshes, and the session
130/// would never age out even if it never actually syncs anything.
131fn fs_last_active(home: &Path) -> Option<SystemTime> {
132    let state = home.join("state").join("wire");
133    ["last_sync.json", "notify.cursor", "reactor.cursor"]
134        .iter()
135        .filter_map(|f| std::fs::metadata(state.join(f)).ok())
136        .filter_map(|m| m.modified().ok())
137        .max()
138}
139
140/// True iff the session home holds a real wire identity (an initialized
141/// `config/wire/private.key`). Such a home ran `wire up`/`init` — it is NOT a
142/// husk, so it must keep a daemon to push its outbox AND pull inbound mail even
143/// when idle + registry-unbound. Otherwise a real-but-idle (or never-synced)
144/// session is starved forever: ineligible → no daemon → never syncs → never
145/// eligible (the wildflower-gleam catch-22 a live audit surfaced — for outbound
146/// mail and, equally, inbound never-pulled mail). Husks (ephemeral read-only
147/// home mints) have no private.key and stay excluded, so the idle filter still
148/// stops their fork-storm. This generalizes #340's pending-outbox keep: a queued
149/// outbox implies an identity, so identity subsumes it. Injected into
150/// `supervisor_eligible` so the filter stays unit-testable.
151fn fs_has_identity(home: &Path) -> bool {
152    // A real wire identity = an initialized `config/wire/private.key`. Same
153    // signal the husk-reaper uses to decide "not a husk".
154    home.join("config")
155        .join("wire")
156        .join("private.key")
157        .exists()
158}
159
160/// True iff the session home has been retired (`state/wire/retired.json`).
161/// A retired home is ineligible for a daemon regardless of cwd/identity/idle —
162/// the supervisor kills any running child and never respawns. Pure existence
163/// check (see [`crate::retire::is_retired`]).
164fn fs_is_retired(home: &Path) -> bool {
165    crate::retire::is_retired(home)
166}
167
168/// Filter `list_sessions()` down to the sessions the supervisor should
169/// own a daemon for. A session is eligible iff it has a registry cwd
170/// binding OR it was active within `max_idle`. `max_idle == None`
171/// disables the filter (every session eligible). Pure: the activity
172/// probe is injected so this is unit-testable without touching disk.
173fn supervisor_eligible<F, G, H>(
174    sessions: Vec<crate::session::SessionInfo>,
175    max_idle: Option<Duration>,
176    now: SystemTime,
177    last_active: F,
178    has_identity: G,
179    is_retired: H,
180) -> Vec<crate::session::SessionInfo>
181where
182    F: Fn(&Path) -> Option<SystemTime>,
183    G: Fn(&Path) -> bool,
184    H: Fn(&Path) -> bool,
185{
186    // Retired homes are ineligible in EVERY configuration — filtered FIRST,
187    // ahead of the `max_idle == None` early-return and the cwd/identity keeps
188    // below. A retired identity's daemon must stop and never respawn; an
189    // `is_retired` check placed after either branch would let a cwd-bound or
190    // identity-ful retired home stay eligible and get respawned.
191    let sessions: Vec<crate::session::SessionInfo> = sessions
192        .into_iter()
193        .filter(|s| !is_retired(&s.home_dir))
194        .collect();
195    let Some(max_idle) = max_idle else {
196        return sessions;
197    };
198    sessions
199        .into_iter()
200        .filter(|s| {
201            if s.cwd.is_some() {
202                return true;
203            }
204            // A real wire identity (private.key) is not a husk → keep a daemon so
205            // it can push its outbox AND pull inbound mail, regardless of idle.
206            // Closes the wildflower-gleam catch-22 (ineligible → no daemon →
207            // never syncs → never eligible) for BOTH outbound-queued and
208            // never-sent/inbound-waiting sessions. Generalizes #340's
209            // pending-outbox keep (an outbox implies an identity). Husks have no
210            // identity → still excluded, so the idle fork-storm guard stands.
211            if has_identity(&s.home_dir) {
212                return true;
213            }
214            match last_active(&s.home_dir) {
215                // `duration_since` errors when the file mtime is in the
216                // future (clock skew) — treat that as "active now".
217                Some(t) => now.duration_since(t).map(|d| d <= max_idle).unwrap_or(true),
218                None => false,
219            }
220        })
221        .collect()
222}
223
224// ---- husk reaper (the 175-dir by-key accumulation fix) ----
225
226/// Default age below which a husk is left alone, in hours. Generous on
227/// purpose: a brand-new agent session may mint its by-key home minutes
228/// before it first inits/sends. Two days is far past any plausible
229/// "about to become real" window while still draining the backlog
230/// (honey-pine regrew 9 husks in one minute; 175 over two weeks).
231const DEFAULT_HUSK_REAP_MAX_AGE_HOURS: u64 = 48;
232
233/// How often the supervisor sweeps for husks. The reap is cheap (one
234/// readdir + a few stats per entry) but there's no reason to run it on
235/// every 10s registry poll — husks age in days, not seconds.
236const HUSK_REAP_INTERVAL: Duration = Duration::from_secs(3600);
237
238/// Parse the husk reap cutoff. `None` raw → default; a `0` value →
239/// `None` (reaper disabled); any other integer → that many hours;
240/// unparseable → default. Pure, mirrors `parse_max_idle`.
241fn parse_husk_reap_max_age(raw: Option<&str>) -> Option<Duration> {
242    match raw {
243        Some(v) => {
244            let hours: u64 = v.trim().parse().unwrap_or(DEFAULT_HUSK_REAP_MAX_AGE_HOURS);
245            (hours != 0).then(|| Duration::from_secs(hours * 3600))
246        }
247        None => Some(Duration::from_secs(DEFAULT_HUSK_REAP_MAX_AGE_HOURS * 3600)),
248    }
249}
250
251/// Read the husk reap cutoff from the environment.
252/// `WIRE_HUSK_REAP_MAX_AGE_HOURS=0` disables the reaper entirely.
253fn husk_reap_max_age_from_env() -> Option<Duration> {
254    parse_husk_reap_max_age(
255        std::env::var("WIRE_HUSK_REAP_MAX_AGE_HOURS")
256            .ok()
257            .as_deref(),
258    )
259}
260
261/// Delete husk session homes under `by_key_root` and return what was
262/// removed.
263///
264/// Every wire invocation inside an agent terminal mints a
265/// `sessions/by-key/<hash>/` home via session adoption (RFC-008), even
266/// for read-only commands, and nothing ever deleted them — a dev box
267/// accumulated 175 empty dirs in two weeks. The idle filter
268/// (`supervisor_eligible`) stops the daemon fork-storm but leaves the
269/// dirs. This is the complement: the filter hides, the reaper removes.
270///
271/// A dir is reaped only if ALL of these hold:
272/// - its name has the by-key shape (exactly 16 lowercase hex chars,
273///   `session_home_for_key`'s output) — named sessions are
274///   operator-created and never touched;
275/// - it holds NO identity (`config/wire/private.key` absent);
276/// - it has never synced (`fs_last_active` → None);
277/// - it is not registry-bound (`bound_names`);
278/// - no live daemon owns it (`daemon_live`, injected for testability);
279/// - it is older than `max_age` (top-dir mtime; future mtimes count as
280///   young — clock-skew never deletes).
281///
282/// Failures are per-entry best-effort (warn + continue): one undeletable
283/// dir must not stop the sweep.
284fn reap_husks<F>(
285    by_key_root: &Path,
286    max_age: Duration,
287    now: SystemTime,
288    bound_names: &std::collections::HashSet<String>,
289    daemon_live: F,
290) -> Vec<PathBuf>
291where
292    F: Fn(&Path) -> bool,
293{
294    let mut reaped = Vec::new();
295    let Ok(entries) = std::fs::read_dir(by_key_root) else {
296        return reaped; // no by-key dir yet — nothing to do
297    };
298    for entry in entries.flatten() {
299        let path = entry.path();
300        if !path.is_dir() {
301            continue;
302        }
303        let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
304            continue;
305        };
306        let is_by_key_shape =
307            name.len() == 16 && name.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
308        if !is_by_key_shape {
309            continue;
310        }
311        if bound_names.contains(name) {
312            continue;
313        }
314        if path
315            .join("config")
316            .join("wire")
317            .join("private.key")
318            .exists()
319        {
320            continue;
321        }
322        if fs_last_active(&path).is_some() {
323            continue;
324        }
325        if daemon_live(&path) {
326            continue;
327        }
328        let old_enough = std::fs::metadata(&path)
329            .and_then(|m| m.modified())
330            .ok()
331            .and_then(|m| now.duration_since(m).ok())
332            .is_some_and(|age| age >= max_age);
333        if !old_enough {
334            continue;
335        }
336        match std::fs::remove_dir_all(&path) {
337            Ok(()) => reaped.push(path),
338            Err(e) => eprintln!("supervisor: husk reap failed for {}: {e:#}", path.display()),
339        }
340    }
341    reaped
342}
343
344/// State the supervisor tracks per session it has spawned a child for.
345struct ChildState {
346    child: Child,
347    spawned_at: Instant,
348}
349
350/// Entrypoint for `wire daemon --all-sessions`. Loops forever; only
351/// returns Err on a setup error (e.g. cannot resolve sessions_root).
352pub fn run_supervisor(interval_secs: u64, as_json: bool) -> Result<()> {
353    // Supervisor singleton — one per machine. Separate pidfile from the
354    // per-session daemon pidfile so the two layers can't collide.
355    let pid_path = supervisor_pid_path()?;
356    if let Some(existing) = read_alive_supervisor_pid(&pid_path)? {
357        let msg = json!({
358            "status": "skipped",
359            "reason": "supervisor already running",
360            "holder_pid": existing,
361        });
362        if as_json {
363            println!("{msg}");
364        } else {
365            eprintln!(
366                "wire daemon --all-sessions: another supervisor is already running (pid {existing}); not starting a second one."
367            );
368        }
369        return Ok(());
370    }
371    write_supervisor_pid(&pid_path)?;
372    let _cleanup = SupervisorPidGuard {
373        path: pid_path.clone(),
374    };
375
376    if !as_json {
377        eprintln!(
378            "wire daemon --all-sessions: supervisor up. interval={interval_secs}s, registry-poll={REGISTRY_POLL_SECS}s. SIGINT to stop."
379        );
380    } else {
381        println!(
382            "{}",
383            json!({
384                "status": "supervisor_started",
385                "interval_secs": interval_secs,
386                "registry_poll_secs": REGISTRY_POLL_SECS,
387            })
388        );
389    }
390
391    // Idle cutoff for registry-unbound sessions — read once at startup
392    // (env doesn't change under a running supervisor).
393    let max_idle = max_idle_from_env();
394    eprintln!(
395        "supervisor: idle cutoff for unbound sessions = {}",
396        match max_idle {
397            Some(d) => format!("{} days", d.as_secs() / 86_400),
398            None => "disabled (spawn-for-all)".to_string(),
399        }
400    );
401
402    // Husk reap cutoff — also read once at startup.
403    let husk_max_age = husk_reap_max_age_from_env();
404    eprintln!(
405        "supervisor: husk reap cutoff = {}",
406        match husk_max_age {
407            Some(d) => format!("{} hours", d.as_secs() / 3600),
408            None => "disabled".to_string(),
409        }
410    );
411    let mut last_husk_reap: Option<Instant> = None;
412
413    let mut children: HashMap<String, ChildState> = HashMap::new();
414    // Per-session backoff that survives a child's reap → respawn → reap
415    // cycle. Distinguishes "session crashes hard repeatedly" from
416    // "child exited cleanly and we're spawning a fresh one".
417    let mut session_last_exit: HashMap<String, Instant> = HashMap::new();
418    let mut session_backoff: HashMap<String, Duration> = HashMap::new();
419
420    loop {
421        // 1. Reap any exited children. Detect rapid failures + update
422        //    per-session backoff so the next spawn waits.
423        let mut exited: Vec<String> = Vec::new();
424        for (name, state) in children.iter_mut() {
425            if let Ok(Some(status)) = state.child.try_wait() {
426                let lived = state.spawned_at.elapsed();
427                let rapid = lived < RAPID_FAIL_WINDOW;
428                eprintln!(
429                    "supervisor: child '{name}' exited (status={status:?}, lived={}s, rapid={rapid})",
430                    lived.as_secs()
431                );
432                let next_backoff = if rapid {
433                    let prev = session_backoff
434                        .get(name)
435                        .copied()
436                        .unwrap_or(INITIAL_BACKOFF);
437                    (prev * 2).min(MAX_BACKOFF)
438                } else {
439                    INITIAL_BACKOFF
440                };
441                session_backoff.insert(name.clone(), next_backoff);
442                session_last_exit.insert(name.clone(), Instant::now());
443                exited.push(name.clone());
444            }
445        }
446        for n in exited {
447            children.remove(&n);
448        }
449
450        // 2. Read registry, identify wanted sessions. Filter out
451        //    registry-unbound sessions that have been idle past the
452        //    cutoff so the supervisor doesn't fan out a daemon per
453        //    every ephemeral persona home (the 147-home fork storm).
454        let all_sessions = crate::session::list_sessions().unwrap_or_default();
455        let total_sessions = all_sessions.len();
456        let wanted: Vec<crate::session::SessionInfo> = supervisor_eligible(
457            all_sessions,
458            max_idle,
459            SystemTime::now(),
460            fs_last_active,
461            fs_has_identity,
462            fs_is_retired,
463        );
464        if wanted.len() != total_sessions {
465            eprintln!(
466                "supervisor: {} of {} sessions eligible (skipped {} registry-unbound + idle > cutoff)",
467                wanted.len(),
468                total_sessions,
469                total_sessions - wanted.len()
470            );
471        }
472
473        // 2b. Hourly husk sweep: delete by-key homes that were minted
474        //     by session adoption but never grew an identity or synced.
475        //     Runs on the first loop iteration, then once per
476        //     HUSK_REAP_INTERVAL.
477        if let Some(max_age) = husk_max_age
478            && last_husk_reap.is_none_or(|t| t.elapsed() >= HUSK_REAP_INTERVAL)
479        {
480            last_husk_reap = Some(Instant::now());
481            let bound: std::collections::HashSet<String> = crate::session::read_registry()
482                .unwrap_or_default()
483                .by_cwd
484                .values()
485                .cloned()
486                .collect();
487            if let Ok(root) = crate::session::sessions_root() {
488                let reaped = reap_husks(
489                    &root.join("by-key"),
490                    max_age,
491                    SystemTime::now(),
492                    &bound,
493                    // On a liveness-probe error assume live — never
494                    // delete a home we couldn't safely inspect.
495                    |home| existing_daemon_for_session(home).unwrap_or(true),
496                );
497                if !reaped.is_empty() {
498                    eprintln!(
499                        "supervisor: reaped {} husk session home(s): {}",
500                        reaped.len(),
501                        reaped
502                            .iter()
503                            .filter_map(|p| p.file_name().and_then(|s| s.to_str()))
504                            .collect::<Vec<_>>()
505                            .join(", ")
506                    );
507                }
508            }
509        }
510
511        // 3. Kill children whose session has been removed from the
512        //    registry since last poll. (Operator ran `wire session
513        //    forget` or similar.)
514        let wanted_names: std::collections::HashSet<String> =
515            wanted.iter().map(|s| s.name.clone()).collect();
516        let to_kill: Vec<String> = children
517            .keys()
518            .filter(|n| !wanted_names.contains(n.as_str()))
519            .cloned()
520            .collect();
521        for name in to_kill {
522            if let Some(mut state) = children.remove(&name) {
523                eprintln!("supervisor: session '{name}' gone from registry; terminating its child");
524                let _ = state.child.kill();
525                let _ = state.child.wait();
526            }
527        }
528
529        // 4. Spawn missing children, respecting backoff + existing
530        //    pidfiles (operator-spawned daemons coexist).
531        for info in wanted {
532            if info.did.is_none() {
533                continue;
534            }
535            if children.contains_key(&info.name) {
536                continue;
537            }
538            // Backoff gate: if this session is in a rapid-fail loop,
539            // wait the remaining backoff before respawning.
540            if let Some(last_exit) = session_last_exit.get(&info.name) {
541                let wait = session_backoff
542                    .get(&info.name)
543                    .copied()
544                    .unwrap_or(INITIAL_BACKOFF);
545                if last_exit.elapsed() < wait {
546                    continue;
547                }
548            }
549            // Singleton check: an operator-spawned `wire daemon` may
550            // already own this session. Leave it alone — re-checking
551            // next poll is cheap.
552            if existing_daemon_for_session(&info.home_dir)? {
553                continue;
554            }
555            match spawn_child_for_session(&info.name, &info.home_dir, interval_secs) {
556                Ok(child) => {
557                    eprintln!(
558                        "supervisor: spawned child for session '{}' (pid {})",
559                        info.name,
560                        child.id()
561                    );
562                    children.insert(
563                        info.name.clone(),
564                        ChildState {
565                            child,
566                            spawned_at: Instant::now(),
567                        },
568                    );
569                }
570                Err(e) => {
571                    eprintln!(
572                        "supervisor: spawn failed for session '{}': {e:#}",
573                        info.name
574                    );
575                    // Treat spawn failure as a rapid failure so the
576                    // backoff curve kicks in.
577                    let prev = session_backoff
578                        .get(&info.name)
579                        .copied()
580                        .unwrap_or(INITIAL_BACKOFF);
581                    session_backoff.insert(info.name.clone(), (prev * 2).min(MAX_BACKOFF));
582                    session_last_exit.insert(info.name.clone(), Instant::now());
583                }
584            }
585        }
586
587        std::thread::sleep(Duration::from_secs(REGISTRY_POLL_SECS));
588    }
589}
590
591/// Spawn `wire daemon --interval <i>` as a child with `WIRE_HOME`
592/// pinned via env. Strips inherited WIRE_* env so the operator's
593/// shell config (test overrides like `WIRE_DAEMON_NO_SINGLETON=1`)
594/// can't leak in.
595///
596/// v0.14.2 #170 hotfix: the original implementation also passed
597/// `--session <character-name>` as a belt-and-suspenders check.
598/// That broke 127 of 133 sessions on a real multi-session box —
599/// `cmd_daemon`'s `--session` handler calls
600/// `session::session_dir(name)` which resolves
601/// `sessions_root/<name>`, correct for v0.6 top-level layout but
602/// WRONG for v0.13's `by-key/<hash>` layout where the character
603/// name is *derived* from the card DID, not the directory name.
604/// Children bailed → supervisor fork-bombed (10s poll × 60s
605/// backoff × 127 failing sessions). WIRE_HOME env alone is the
606/// correct contract: every daemon code path flows through
607/// `state_dir()` / `config_dir()` which honor it. No second
608/// source of truth.
609fn spawn_child_for_session(
610    name: &str,
611    home_dir: &std::path::Path,
612    interval_secs: u64,
613) -> Result<Child> {
614    let exe = std::env::current_exe().context("resolving current exe for child fork")?;
615    let mut cmd = Command::new(&exe);
616    cmd.args(["daemon", "--interval", &interval_secs.to_string()]);
617    // Strip WIRE_* env so operator shell-vars don't leak into the
618    // child. Then pin WIRE_HOME exactly.
619    let leaks: Vec<String> = std::env::vars()
620        .filter(|(k, _)| k.starts_with("WIRE_"))
621        .map(|(k, _)| k)
622        .collect();
623    for k in leaks {
624        cmd.env_remove(&k);
625    }
626    cmd.env("WIRE_HOME", home_dir);
627    // Children inherit stdout/stderr → land in the launchd log file
628    // (StandardOutPath in the plist). Operators see "supervisor:
629    // spawned ..." lines interleaved with each session's daemon log.
630    cmd.spawn().with_context(|| {
631        format!(
632            "fork-exec `wire daemon` for session '{name}' (binary {} WIRE_HOME={})",
633            exe.display(),
634            home_dir.display()
635        )
636    })
637}
638
639/// True iff this session's `daemon.pid` names a live process. Used by
640/// the supervisor to coexist with operator-spawned `wire daemon`
641/// invocations: if the operator already started one in a tmux pane,
642/// we skip the spawn and let theirs own the cursor.
643fn existing_daemon_for_session(home_dir: &std::path::Path) -> Result<bool> {
644    let pid_path = home_dir.join("state").join("wire").join("daemon.pid");
645    if !pid_path.exists() {
646        return Ok(false);
647    }
648    let body = match std::fs::read_to_string(&pid_path) {
649        Ok(b) => b,
650        Err(_) => return Ok(false),
651    };
652    // Pidfile is either JSON `{"pid": <n>, ...}` (v0.5.11+) or a bare
653    // integer (legacy). Try JSON+pid-field first; if that yields
654    // None (parse failed OR JSON had no pid field, e.g. a bare
655    // integer body parses as JSON number with no `.pid`), fall
656    // through to the bare-int path.
657    let pid = serde_json::from_str::<serde_json::Value>(&body)
658        .ok()
659        .and_then(|v| v.get("pid").and_then(serde_json::Value::as_u64))
660        .or_else(|| body.trim().parse::<u64>().ok());
661    Ok(pid
662        .map(|p| crate::ensure_up::pid_is_alive(p as u32))
663        .unwrap_or(false))
664}
665
666/// Read-only snapshot of the supervisor's current topology — supervisor
667/// liveness + per-session daemon liveness + orphan pids the supervisor
668/// is not currently managing. Used by `wire supervisor` (the CLI
669/// counterpart to single-session `wire status`) so operators can ask
670/// "what is the multi-session supervisor doing?" in one command
671/// instead of cross-referencing `pgrep` against per-session pidfiles
672/// by hand.
673#[derive(Debug, Clone, serde::Serialize)]
674pub struct SupervisorState {
675    /// Pid the `supervisor.pid` file names; None if file missing.
676    pub supervisor_pid: Option<u32>,
677    /// True iff that pid is currently a live process.
678    pub supervisor_alive: bool,
679    /// Per-session liveness across every initialized session, in
680    /// `list_sessions()` order.
681    pub sessions: Vec<SupervisedSession>,
682    /// `wire daemon` pids found via cmdline-scan that are NOT mapped
683    /// to any session's pidfile AND are not the supervisor itself.
684    /// Could be legacy operator-spawned daemons, leftover children
685    /// from a crashed prior supervisor, or daemons serving the
686    /// default WIRE_HOME (no `--all-sessions`). Operators see them
687    /// here so they can decide whether to kill.
688    pub unmanaged_pids: Vec<u32>,
689    /// v0.14.2: session names whose live daemon's recorded
690    /// `pidfile.version` is older than this CLI's own
691    /// `CARGO_PKG_VERSION`. The supervisor's existing-pidfile check
692    /// skips alive daemons regardless of their binary version, so
693    /// stale-binary daemons persist until they exit. Surfaced for
694    /// operator visibility — they can `pkill -TERM <pid>` or use a
695    /// future `wire upgrade --refresh-stale-children` to force the
696    /// supervisor to respawn them on the current binary.
697    pub stale_binary_sessions: Vec<String>,
698    /// v0.16.x (#275): the subset of `stale_binary_sessions` the
699    /// `--all-sessions` supervisor would NOT respawn — i.e. sessions
700    /// the supervisor's eligibility filter (`supervisor_eligible`:
701    /// registry-bound OR active within the idle cutoff) drops. Killing
702    /// one of these (which `wire upgrade --refresh-stale-children` used
703    /// to do indiscriminately) orphans it: the supervisor never brings
704    /// it back, so the identity silently stops syncing. `wire upgrade`
705    /// must NOT kill these — it surfaces them as "relaunch manually"
706    /// instead.
707    pub stale_unmanaged_sessions: Vec<String>,
708}
709
710/// One session as seen by the supervisor.
711#[derive(Debug, Clone, serde::Serialize)]
712pub struct SupervisedSession {
713    /// Session name (`info.name` from `session::list_sessions`).
714    pub name: String,
715    /// `home_dir` filesystem path.
716    pub home_dir: String,
717    /// Pid the session's `daemon.pid` records; None if file missing.
718    pub daemon_pid: Option<u32>,
719    /// True iff that pid is currently a live process.
720    pub daemon_alive: bool,
721    /// Seconds since the session's daemon last completed a sync
722    /// cycle (read from `last_sync.json`); None if never recorded.
723    pub last_sync_age_seconds: Option<u64>,
724    /// Version string the running daemon recorded when it wrote its
725    /// pidfile (`PidRecord::Json.version`). None when the pidfile is
726    /// missing or corrupt. Surfaced so operators can spot version drift across
727    /// the supervisor fleet — the supervisor's pre-spawn
728    /// existing-pidfile check skips alive daemons regardless of
729    /// their binary version, so a daemon spawned on v0.13.x and
730    /// still running after the supervisor was bounced to v0.14.x
731    /// keeps the old binary in memory until it exits.
732    #[serde(skip_serializing_if = "Option::is_none")]
733    pub daemon_version: Option<String>,
734}
735
736/// Build a `SupervisorState` snapshot. Pure read; no fork / no
737/// pidfile mutation. Best-effort on every component (filesystem
738/// errors yield None / empty rather than failing the whole call).
739pub fn read_supervisor_state() -> Result<SupervisorState> {
740    let pid_path = supervisor_pid_path()?;
741    let supervisor_pid = read_supervisor_pid(&pid_path);
742    let supervisor_alive = supervisor_pid
743        .map(crate::ensure_up::pid_is_alive)
744        .unwrap_or(false);
745
746    // Per-session liveness — walk list_sessions, read each home's
747    // pidfile + last_sync.
748    let infos = crate::session::list_sessions().unwrap_or_default();
749
750    // #275: the names the `--all-sessions` supervisor would actually own a
751    // daemon for, computed with the SAME predicate the supervisor loop uses
752    // (`supervisor_eligible`: registry-bound OR active within the idle cutoff).
753    // Used below to flag stale sessions the supervisor will NOT respawn, so
754    // `wire upgrade --refresh-stale-children` doesn't kill-and-orphan them.
755    let eligible_names: std::collections::HashSet<String> = supervisor_eligible(
756        infos.clone(),
757        max_idle_from_env(),
758        SystemTime::now(),
759        fs_last_active,
760        fs_has_identity,
761        fs_is_retired,
762    )
763    .into_iter()
764    .map(|s| s.name)
765    .collect();
766
767    let sessions: Vec<SupervisedSession> = infos
768        .into_iter()
769        .map(|info| {
770            let daemon_pid = crate::session::session_daemon_pid(&info.home_dir);
771            let daemon_alive = daemon_pid
772                .map(crate::ensure_up::pid_is_alive)
773                .unwrap_or(false);
774            // last_sync.json lives under <home>/state/wire/last_sync.json.
775            let last_sync_age_seconds = read_session_last_sync_age(&info.home_dir);
776            // v0.14.2: read the daemon-recorded version from the JSON
777            // pidfile. Legacy bare-integer pidfiles return None
778            // (can't surface a version we don't have).
779            let daemon_version = read_session_pidfile_version(&info.home_dir);
780            SupervisedSession {
781                name: info.name,
782                home_dir: info.home_dir.to_string_lossy().into_owned(),
783                daemon_pid,
784                daemon_alive,
785                last_sync_age_seconds,
786                daemon_version,
787            }
788        })
789        .collect();
790
791    // Unmanaged pids: every `wire daemon` cmdline scan hit that isn't
792    // (a) the supervisor itself, (b) any session's pidfile pid.
793    let all_daemon_pids: std::collections::HashSet<u32> =
794        crate::platform::find_processes_by_cmdline("wire daemon")
795            .into_iter()
796            .collect();
797    let known_session_pids: std::collections::HashSet<u32> = sessions
798        .iter()
799        .filter_map(|s| if s.daemon_alive { s.daemon_pid } else { None })
800        .collect();
801    let mut unmanaged_pids: Vec<u32> = all_daemon_pids
802        .into_iter()
803        .filter(|p| Some(*p) != supervisor_pid && !known_session_pids.contains(p))
804        .collect();
805    unmanaged_pids.sort_unstable();
806
807    // v0.14.2: derive the stale-binary set. Compare each live
808    // daemon's recorded version against the running CLI's version.
809    // "Stale" iff alive + has a recorded version + that version is
810    // strictly less than ours by dotted-integer compare (so 0.10.0 >
811    // 0.9.0). Unparseable strings are conservatively "not stale" — a
812    // pre-release suffix like 0.14.2-rc.1 stays unflagged rather than
813    // false-positive against 0.14.2.
814    let our_version = env!("CARGO_PKG_VERSION");
815    let stale_binary_sessions: Vec<String> = sessions
816        .iter()
817        .filter(|s| {
818            s.daemon_alive
819                && s.daemon_version
820                    .as_deref()
821                    .map(|v| version_lt(v, our_version))
822                    .unwrap_or(false)
823        })
824        .map(|s| s.name.clone())
825        .collect();
826
827    // #275: split the stale set by whether the supervisor would respawn it.
828    // The "unmanaged" ones must not be killed by `--refresh-stale-children`.
829    let (_respawnable, stale_unmanaged_sessions) =
830        partition_stale_by_eligibility(&stale_binary_sessions, &eligible_names);
831
832    Ok(SupervisorState {
833        supervisor_pid,
834        supervisor_alive,
835        sessions,
836        unmanaged_pids,
837        stale_binary_sessions,
838        stale_unmanaged_sessions,
839    })
840}
841
842/// Split stale-binary session names into `(respawnable, unmanaged)`: a stale
843/// session is respawnable iff the `--all-sessions` supervisor would re-own it
844/// (its name is in `eligible`). The `unmanaged` ones are stale daemons the
845/// supervisor's eligibility filter drops (unbound + idle past the cutoff, or
846/// never-synced) — killing one orphans it because nothing respawns it. Pure +
847/// unit-tested so `wire upgrade --refresh-stale-children`'s "don't kill what
848/// you can't respawn" contract (#275) is locked. Order-preserving.
849fn partition_stale_by_eligibility(
850    stale: &[String],
851    eligible: &std::collections::HashSet<String>,
852) -> (Vec<String>, Vec<String>) {
853    stale
854        .iter()
855        .cloned()
856        .partition(|name| eligible.contains(name))
857}
858
859/// Compare two dotted-integer version strings: `a < b`?
860///
861/// Splits on `.`, parses each segment as `u32`, compares
862/// element-wise (left-pad shorter with 0 so `0.14` < `0.14.1` is
863/// `true`). Anything that fails to parse as `u32` makes the whole
864/// compare return `false` — we'd rather under-flag a pre-release
865/// suffix like `0.14.2-rc.1` than false-positive against a stable
866/// peer of the same major.minor.patch.
867fn version_lt(a: &str, b: &str) -> bool {
868    let parse = |s: &str| -> Option<Vec<u32>> { s.split('.').map(|p| p.parse().ok()).collect() };
869    let (Some(av), Some(bv)) = (parse(a), parse(b)) else {
870        return false;
871    };
872    let n = av.len().max(bv.len());
873    for i in 0..n {
874        let ai = av.get(i).copied().unwrap_or(0);
875        let bi = bv.get(i).copied().unwrap_or(0);
876        if ai != bi {
877            return ai < bi;
878        }
879    }
880    false
881}
882
883/// Read the daemon-recorded version string from a session's
884/// `<home>/state/wire/daemon.pid` JSON pidfile. Returns None for
885/// legacy bare-integer pidfiles (no version field) and for absent /
886/// unreadable files.
887fn read_session_pidfile_version(home_dir: &std::path::Path) -> Option<String> {
888    let pidfile = home_dir.join("state").join("wire").join("daemon.pid");
889    let body = std::fs::read_to_string(&pidfile).ok()?;
890    let v: serde_json::Value = serde_json::from_str(&body).ok()?;
891    v.get("version")
892        .and_then(serde_json::Value::as_str)
893        .map(str::to_string)
894}
895
896/// Read `supervisor.pid` without the liveness check (the snapshot
897/// builder runs the check itself, separated so an absent file is
898/// just `None` rather than an Err).
899fn read_supervisor_pid(path: &std::path::Path) -> Option<u32> {
900    if !path.exists() {
901        return None;
902    }
903    let body = std::fs::read_to_string(path).ok()?;
904    body.trim().parse::<u32>().ok()
905}
906
907/// Read `<home>/state/wire/last_sync.json`'s timestamp and return
908/// "seconds since now". None on absent / unreadable / unparseable.
909fn read_session_last_sync_age(home_dir: &std::path::Path) -> Option<u64> {
910    let path = home_dir.join("state").join("wire").join("last_sync.json");
911    let body = std::fs::read_to_string(&path).ok()?;
912    let v: serde_json::Value = serde_json::from_str(&body).ok()?;
913    let ts = v.get("ts").and_then(serde_json::Value::as_str)?;
914    let parsed =
915        time::OffsetDateTime::parse(ts, &time::format_description::well_known::Rfc3339).ok()?;
916    let age = (time::OffsetDateTime::now_utc() - parsed).whole_seconds();
917    if age < 0 {
918        // Clock skew: timestamp is in the future. Treat as fresh.
919        Some(0)
920    } else {
921        Some(age as u64)
922    }
923}
924
925fn supervisor_pid_path() -> Result<PathBuf> {
926    let root = crate::session::sessions_root()
927        .context("resolving sessions_root for supervisor pidfile")?;
928    std::fs::create_dir_all(&root).with_context(|| format!("creating {root:?}"))?;
929    Ok(root.join("supervisor.pid"))
930}
931
932fn read_alive_supervisor_pid(path: &std::path::Path) -> Result<Option<u32>> {
933    if !path.exists() {
934        return Ok(None);
935    }
936    let body = std::fs::read_to_string(path).ok();
937    let pid = body.as_deref().and_then(|s| s.trim().parse::<u32>().ok());
938    match pid {
939        Some(p) if crate::ensure_up::pid_is_alive(p) => Ok(Some(p)),
940        _ => Ok(None),
941    }
942}
943
944fn write_supervisor_pid(path: &std::path::Path) -> Result<()> {
945    let pid = std::process::id();
946    std::fs::write(path, pid.to_string())
947        .with_context(|| format!("writing supervisor pidfile {path:?}"))?;
948    Ok(())
949}
950
951struct SupervisorPidGuard {
952    path: PathBuf,
953}
954
955impl Drop for SupervisorPidGuard {
956    fn drop(&mut self) {
957        // Only remove if it still names us — same pattern as
958        // DaemonPidGuard in ensure_up.rs.
959        if let Ok(body) = std::fs::read_to_string(&self.path)
960            && let Ok(pid) = body.trim().parse::<u32>()
961            && pid == std::process::id()
962        {
963            let _ = std::fs::remove_file(&self.path);
964        }
965    }
966}
967
968#[cfg(test)]
969mod tests {
970    use super::*;
971    use tempfile::tempdir;
972
973    #[test]
974    fn version_lt_dotted_integer_compare() {
975        // Lexical string-compare footgun cases — these must come out right.
976        assert!(version_lt("0.9.0", "0.10.0"));
977        assert!(version_lt("0.13.5", "0.14.1"));
978        assert!(version_lt("0.14.0", "0.14.1"));
979        // Equal / greater → not stale.
980        assert!(!version_lt("0.14.1", "0.14.1"));
981        assert!(!version_lt("0.14.2", "0.14.1"));
982        // Shorter version pads with zero.
983        assert!(version_lt("0.14", "0.14.1"));
984        assert!(!version_lt("0.14.1", "0.14"));
985        // Unparseable (pre-release suffix, garbage) is conservatively NOT-stale
986        // — under-flagging beats false-positive on `0.14.2-rc.1` vs `0.14.2`.
987        assert!(!version_lt("0.14.2-rc.1", "0.14.2"));
988        assert!(!version_lt("garbage", "0.14.1"));
989        assert!(!version_lt("0.14.1", "garbage"));
990    }
991
992    #[test]
993    fn read_alive_supervisor_pid_returns_none_when_missing() {
994        let tmp = tempdir().unwrap();
995        let p = tmp.path().join("supervisor.pid");
996        assert_eq!(read_alive_supervisor_pid(&p).unwrap(), None);
997    }
998
999    #[test]
1000    fn read_alive_supervisor_pid_returns_none_for_dead_pid() {
1001        let tmp = tempdir().unwrap();
1002        let p = tmp.path().join("supervisor.pid");
1003        // pid 999999 is almost certainly not running.
1004        std::fs::write(&p, "999999").unwrap();
1005        assert_eq!(read_alive_supervisor_pid(&p).unwrap(), None);
1006    }
1007
1008    #[test]
1009    fn read_alive_supervisor_pid_returns_pid_for_self() {
1010        let tmp = tempdir().unwrap();
1011        let p = tmp.path().join("supervisor.pid");
1012        let our_pid = std::process::id();
1013        std::fs::write(&p, our_pid.to_string()).unwrap();
1014        assert_eq!(read_alive_supervisor_pid(&p).unwrap(), Some(our_pid));
1015    }
1016
1017    #[test]
1018    fn pid_guard_only_removes_when_pid_still_matches() {
1019        let tmp = tempdir().unwrap();
1020        let p = tmp.path().join("supervisor.pid");
1021        // Write a foreign pid into the file, then drop a guard for
1022        // our pid. The guard should leave the foreign pidfile alone.
1023        std::fs::write(&p, "12345").unwrap();
1024        {
1025            let _g = SupervisorPidGuard { path: p.clone() };
1026        }
1027        assert!(p.exists(), "guard removed a pidfile that didn't name us");
1028    }
1029
1030    #[test]
1031    fn pid_guard_removes_when_pid_matches() {
1032        let tmp = tempdir().unwrap();
1033        let p = tmp.path().join("supervisor.pid");
1034        let our_pid = std::process::id();
1035        std::fs::write(&p, our_pid.to_string()).unwrap();
1036        {
1037            let _g = SupervisorPidGuard { path: p.clone() };
1038        }
1039        assert!(!p.exists(), "guard left our own pidfile behind");
1040    }
1041
1042    #[test]
1043    fn existing_daemon_for_session_returns_false_when_pidfile_missing() {
1044        let tmp = tempdir().unwrap();
1045        // home_dir has no state/wire/daemon.pid
1046        assert!(!existing_daemon_for_session(tmp.path()).unwrap());
1047    }
1048
1049    #[test]
1050    fn existing_daemon_for_session_returns_false_for_dead_pid() {
1051        let tmp = tempdir().unwrap();
1052        let state = tmp.path().join("state").join("wire");
1053        std::fs::create_dir_all(&state).unwrap();
1054        std::fs::write(state.join("daemon.pid"), "999999").unwrap();
1055        assert!(!existing_daemon_for_session(tmp.path()).unwrap());
1056    }
1057
1058    #[test]
1059    fn existing_daemon_for_session_returns_true_for_self_pid() {
1060        let tmp = tempdir().unwrap();
1061        let state = tmp.path().join("state").join("wire");
1062        std::fs::create_dir_all(&state).unwrap();
1063        std::fs::write(state.join("daemon.pid"), std::process::id().to_string()).unwrap();
1064        assert!(existing_daemon_for_session(tmp.path()).unwrap());
1065    }
1066
1067    // ---- supervisor eligibility filter (the 147-home fork-storm fix) ----
1068
1069    fn mk_session(name: &str, cwd: Option<&str>) -> crate::session::SessionInfo {
1070        crate::session::SessionInfo {
1071            name: name.to_string(),
1072            cwd: cwd.map(String::from),
1073            home_dir: PathBuf::from(format!("/sessions/{name}")),
1074            did: None,
1075            handle: None,
1076            daemon_running: false,
1077            character: None,
1078        }
1079    }
1080
1081    #[test]
1082    fn parse_max_idle_default_when_unset() {
1083        assert_eq!(
1084            parse_max_idle(None),
1085            Some(Duration::from_secs(DEFAULT_MAX_IDLE_DAYS * 86_400))
1086        );
1087    }
1088
1089    #[test]
1090    fn parse_max_idle_zero_disables_filter() {
1091        assert_eq!(parse_max_idle(Some("0")), None);
1092    }
1093
1094    #[test]
1095    fn parse_max_idle_explicit_days() {
1096        assert_eq!(
1097            parse_max_idle(Some("3")),
1098            Some(Duration::from_secs(3 * 86_400))
1099        );
1100        assert_eq!(
1101            parse_max_idle(Some("  14 ")),
1102            Some(Duration::from_secs(14 * 86_400))
1103        );
1104    }
1105
1106    #[test]
1107    fn parse_max_idle_garbage_falls_back_to_default() {
1108        assert_eq!(
1109            parse_max_idle(Some("not-a-number")),
1110            Some(Duration::from_secs(DEFAULT_MAX_IDLE_DAYS * 86_400))
1111        );
1112    }
1113
1114    #[test]
1115    fn partition_stale_splits_respawnable_from_unmanaged() {
1116        // #275: stale sessions the supervisor would respawn (eligible) vs ones
1117        // it would orphan. `wire upgrade --refresh-stale-children` may kill the
1118        // former (supervisor brings them back) but must leave the latter.
1119        let stale = vec![
1120            "bound".to_string(),
1121            "active".to_string(),
1122            "orphan".to_string(),
1123        ];
1124        let eligible: std::collections::HashSet<String> =
1125            ["bound".to_string(), "active".to_string()]
1126                .into_iter()
1127                .collect();
1128        let (respawnable, unmanaged) = partition_stale_by_eligibility(&stale, &eligible);
1129        assert_eq!(respawnable, vec!["bound".to_string(), "active".to_string()]);
1130        assert_eq!(unmanaged, vec!["orphan".to_string()]);
1131    }
1132
1133    #[test]
1134    fn partition_stale_all_unmanaged_when_none_eligible() {
1135        // No supervisor-eligible sessions → every stale daemon is unmanaged →
1136        // none may be killed (the silent-orphan footgun from #275).
1137        let stale = vec!["a".to_string(), "b".to_string()];
1138        let eligible = std::collections::HashSet::new();
1139        let (respawnable, unmanaged) = partition_stale_by_eligibility(&stale, &eligible);
1140        assert!(respawnable.is_empty());
1141        assert_eq!(unmanaged, stale);
1142    }
1143
1144    #[test]
1145    fn eligible_keeps_cwd_bound_even_when_ancient() {
1146        // A registry-bound session is kept no matter how idle — the
1147        // operator deliberately attached it to a project dir. (This is
1148        // the real-world case: the cwd-bound `wire`/`slancha-*` sessions
1149        // were the *oldest* on the box, yet must survive.)
1150        let now = SystemTime::now();
1151        let ancient = now - Duration::from_secs(365 * 86_400);
1152        let sessions = vec![mk_session("wire", Some("/Users/p/Source/wire"))];
1153        let out = supervisor_eligible(
1154            sessions,
1155            Some(Duration::from_secs(7 * 86_400)),
1156            now,
1157            |_| Some(ancient),
1158            |_| false,
1159            |_| false,
1160        );
1161        assert_eq!(out.len(), 1);
1162        assert_eq!(out[0].name, "wire");
1163    }
1164
1165    #[test]
1166    fn eligible_keeps_unbound_recent_drops_unbound_idle() {
1167        // The live-but-unbound persona sessions (each Claude tab) are
1168        // recent → kept. The abandoned ones are idle → dropped.
1169        let now = SystemTime::now();
1170        let recent = now - Duration::from_secs(2 * 86_400);
1171        let stale = now - Duration::from_secs(30 * 86_400);
1172        let sessions = vec![
1173            mk_session("rosy-rook", None),    // live tab
1174            mk_session("agate-nimbus", None), // abandoned
1175        ];
1176        let out = supervisor_eligible(
1177            sessions,
1178            Some(Duration::from_secs(7 * 86_400)),
1179            now,
1180            |home| {
1181                if home.ends_with("rosy-rook") {
1182                    Some(recent)
1183                } else {
1184                    Some(stale)
1185                }
1186            },
1187            |_| false,
1188            |_| false,
1189        );
1190        let names: Vec<_> = out.iter().map(|s| s.name.as_str()).collect();
1191        assert_eq!(names, vec!["rosy-rook"]);
1192    }
1193
1194    #[test]
1195    fn eligible_drops_unbound_with_no_activity_signal() {
1196        // A never-synced husk (no activity files at all) and no cwd →
1197        // dropped: nothing says it's a session anyone is using.
1198        let now = SystemTime::now();
1199        let sessions = vec![mk_session("husk", None)];
1200        let out = supervisor_eligible(
1201            sessions,
1202            Some(Duration::from_secs(7 * 86_400)),
1203            now,
1204            |_| None,
1205            |_| false,
1206            |_| false,
1207        );
1208        assert!(out.is_empty());
1209    }
1210
1211    #[test]
1212    fn eligible_keeps_unbound_idle_session_with_identity() {
1213        // The wildflower-gleam catch-22, generalized: no cwd binding, never
1214        // synced (last_active None → normally dropped). A home with a real
1215        // identity (private.key) MUST stay eligible so a daemon spawns to push
1216        // its outbox AND pull inbound mail — otherwise it strands forever
1217        // (ineligible → no daemon → never syncs → never eligible). The
1218        // identity-less husk sibling is still correctly dropped.
1219        let now = SystemTime::now();
1220        let sessions = vec![mk_session("real", None), mk_session("husk", None)];
1221        let out = supervisor_eligible(
1222            sessions,
1223            Some(Duration::from_secs(7 * 86_400)),
1224            now,
1225            |_| None,                      // neither has ever synced
1226            |home| home.ends_with("real"), // only this one has a private.key
1227            |_| false,
1228        );
1229        let names: Vec<_> = out.iter().map(|s| s.name.as_str()).collect();
1230        assert_eq!(names, vec!["real"]);
1231    }
1232
1233    #[test]
1234    fn eligible_drops_retired_even_with_cwd_identity_or_no_cutoff() {
1235        // The retire invariant: a `.retired` home is ineligible in EVERY
1236        // configuration — with a cwd binding, with an identity, and even under
1237        // `max_idle = None` (the spawn-for-all override). All three would
1238        // otherwise keep it eligible; the retired filter must beat them all.
1239        let now = SystemTime::now();
1240        let sessions = vec![
1241            mk_session("retired-proj", Some("/Users/p/proj")), // cwd-bound
1242            mk_session("retired-id", None),                    // identity-ful
1243            mk_session("live", Some("/Users/p/live")),
1244        ];
1245        let retired = |home: &Path| home.to_string_lossy().contains("retired-");
1246        // With the 7-day cutoff.
1247        let out = supervisor_eligible(
1248            sessions.clone(),
1249            Some(Duration::from_secs(7 * 86_400)),
1250            now,
1251            |_| Some(now),
1252            |home| home.ends_with("retired-id"), // retired-id has identity
1253            retired,
1254        );
1255        assert_eq!(
1256            out.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
1257            vec!["live"],
1258            "both retired homes dropped despite cwd/identity"
1259        );
1260        // And under the max_idle=None spawn-for-all override.
1261        let out2 = supervisor_eligible(sessions, None, now, |_| Some(now), |_| true, retired);
1262        assert_eq!(
1263            out2.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
1264            vec!["live"],
1265            "retired dropped even with max_idle=None"
1266        );
1267    }
1268
1269    #[test]
1270    fn eligible_none_cutoff_keeps_everything() {
1271        // Override = 0 (max_idle None) restores legacy spawn-for-all.
1272        let now = SystemTime::now();
1273        let ancient = now - Duration::from_secs(999 * 86_400);
1274        let sessions = vec![mk_session("husk", None), mk_session("agate-nimbus", None)];
1275        let out = supervisor_eligible(sessions, None, now, |_| Some(ancient), |_| false, |_| false);
1276        assert_eq!(out.len(), 2);
1277    }
1278
1279    // ---- husk reaper ----
1280
1281    use std::collections::HashSet;
1282
1283    /// Make a by-key-shaped husk home (`state/wire` only, no identity)
1284    /// under `root` and return its path. The dir's real mtime is "now",
1285    /// so tests control age by passing a future `now` to `reap_husks`.
1286    fn mk_husk(root: &Path, name: &str) -> PathBuf {
1287        let home = root.join(name);
1288        std::fs::create_dir_all(home.join("state").join("wire")).unwrap();
1289        home
1290    }
1291
1292    /// `now` far enough in the future that any just-created dir is past
1293    /// the default 48h cutoff.
1294    fn far_future() -> SystemTime {
1295        SystemTime::now() + Duration::from_secs(100 * 3600)
1296    }
1297
1298    const CUTOFF_48H: Duration = Duration::from_secs(48 * 3600);
1299
1300    #[test]
1301    fn reap_removes_old_identityless_unsynced_husk() {
1302        let tmp = tempdir().unwrap();
1303        let home = mk_husk(tmp.path(), "abcdef0123456789");
1304        let reaped = reap_husks(
1305            tmp.path(),
1306            CUTOFF_48H,
1307            far_future(),
1308            &HashSet::new(),
1309            |_| false,
1310        );
1311        assert_eq!(reaped, vec![home.clone()]);
1312        assert!(!home.exists(), "husk dir should be gone");
1313    }
1314
1315    #[test]
1316    fn reap_keeps_identity_homes_regardless_of_age() {
1317        let tmp = tempdir().unwrap();
1318        let home = mk_husk(tmp.path(), "abcdef0123456789");
1319        let cfg = home.join("config").join("wire");
1320        std::fs::create_dir_all(&cfg).unwrap();
1321        std::fs::write(cfg.join("private.key"), "k").unwrap();
1322        let reaped = reap_husks(
1323            tmp.path(),
1324            CUTOFF_48H,
1325            far_future(),
1326            &HashSet::new(),
1327            |_| false,
1328        );
1329        assert!(reaped.is_empty());
1330        assert!(home.exists(), "identity-bearing home must never be reaped");
1331    }
1332
1333    #[test]
1334    fn reap_keeps_homes_that_ever_synced() {
1335        let tmp = tempdir().unwrap();
1336        let home = mk_husk(tmp.path(), "abcdef0123456789");
1337        std::fs::write(home.join("state").join("wire").join("last_sync.json"), "{}").unwrap();
1338        let reaped = reap_husks(
1339            tmp.path(),
1340            CUTOFF_48H,
1341            far_future(),
1342            &HashSet::new(),
1343            |_| false,
1344        );
1345        assert!(reaped.is_empty());
1346        assert!(home.exists(), "synced home must never be reaped");
1347    }
1348
1349    #[test]
1350    fn reap_keeps_young_husks() {
1351        let tmp = tempdir().unwrap();
1352        let home = mk_husk(tmp.path(), "abcdef0123456789");
1353        // `now` = actual now → dir age ≈ 0 < 48h.
1354        let reaped = reap_husks(
1355            tmp.path(),
1356            CUTOFF_48H,
1357            SystemTime::now(),
1358            &HashSet::new(),
1359            |_| false,
1360        );
1361        assert!(reaped.is_empty());
1362        assert!(home.exists(), "young husk must get its grace window");
1363    }
1364
1365    #[test]
1366    fn reap_keeps_registry_bound_names() {
1367        let tmp = tempdir().unwrap();
1368        let home = mk_husk(tmp.path(), "abcdef0123456789");
1369        let bound: HashSet<String> = ["abcdef0123456789".to_string()].into();
1370        let reaped = reap_husks(tmp.path(), CUTOFF_48H, far_future(), &bound, |_| false);
1371        assert!(reaped.is_empty());
1372        assert!(home.exists(), "operator-bound home must never be reaped");
1373    }
1374
1375    #[test]
1376    fn reap_keeps_homes_with_live_daemon() {
1377        let tmp = tempdir().unwrap();
1378        let home = mk_husk(tmp.path(), "abcdef0123456789");
1379        let reaped = reap_husks(
1380            tmp.path(),
1381            CUTOFF_48H,
1382            far_future(),
1383            &HashSet::new(),
1384            |_| true,
1385        );
1386        assert!(reaped.is_empty());
1387        assert!(home.exists(), "daemon-owned home must never be reaped");
1388    }
1389
1390    #[test]
1391    fn reap_ignores_non_by_key_shaped_names() {
1392        let tmp = tempdir().unwrap();
1393        // Named session, uppercase hex, and wrong-length hex — all
1394        // outside the by-key shape, all untouchable.
1395        let named = mk_husk(tmp.path(), "my-session");
1396        let upper = mk_husk(tmp.path(), "ABCDEF0123456789");
1397        let short = mk_husk(tmp.path(), "abcdef012345678");
1398        let reaped = reap_husks(
1399            tmp.path(),
1400            CUTOFF_48H,
1401            far_future(),
1402            &HashSet::new(),
1403            |_| false,
1404        );
1405        assert!(reaped.is_empty());
1406        assert!(named.exists() && upper.exists() && short.exists());
1407    }
1408
1409    #[test]
1410    fn reap_missing_root_is_a_noop() {
1411        let tmp = tempdir().unwrap();
1412        let reaped = reap_husks(
1413            &tmp.path().join("no-such-by-key"),
1414            CUTOFF_48H,
1415            far_future(),
1416            &HashSet::new(),
1417            |_| false,
1418        );
1419        assert!(reaped.is_empty());
1420    }
1421
1422    #[test]
1423    fn husk_reap_max_age_parsing() {
1424        // Unset → 48h default.
1425        assert_eq!(
1426            parse_husk_reap_max_age(None),
1427            Some(Duration::from_secs(48 * 3600))
1428        );
1429        // 0 → disabled.
1430        assert_eq!(parse_husk_reap_max_age(Some("0")), None);
1431        // Explicit hours.
1432        assert_eq!(
1433            parse_husk_reap_max_age(Some("12")),
1434            Some(Duration::from_secs(12 * 3600))
1435        );
1436        // Garbage → default, not disabled.
1437        assert_eq!(
1438            parse_husk_reap_max_age(Some("soon")),
1439            Some(Duration::from_secs(48 * 3600))
1440        );
1441    }
1442}