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}