pub mod api;
pub mod audit;
pub mod bug_report;
pub mod build_lease_routes;
pub mod builder_slot_routes;
pub mod bus;
pub mod catchup_bounds;
pub mod catchup_payload;
pub mod catchup_superseded;
pub mod claude_config;
pub mod coordinator;
pub mod delegation_routes;
pub mod discovery;
pub mod doctor;
pub mod doctor_auto_memory;
pub mod doctor_launchd_secrets;
pub mod doctor_launchd_secrets_repair;
pub mod doctor_launchd_secrets_scoped;
pub mod doctor_credential_reach;
mod doctor_startup_context;
pub mod doctor_launchd_process_type;
mod doctor_tmux_priority;
mod doctor_tcp_listeners;
mod doctor_content;
pub mod error;
pub mod idle_nudge;
pub mod idle_reaper;
mod legacy_reap;
pub mod llm_overseer;
pub mod lock;
pub mod log_drain;
pub mod managed_routes;
pub mod manager;
pub mod mcp_backend;
pub mod mcp_bugreport;
pub mod mcp_console;
pub mod mcp_context;
pub mod mcp_disk;
pub(crate) mod disk_survey_cache;
pub mod mcp_project;
pub mod mcp_proxy;
pub mod mcp_session;
pub mod project_adoption;
pub mod mcp_session_bulk;
pub mod openapi;
pub mod optimizer;
pub mod orphan_gc;
pub mod overseer_compose;
pub mod pairing_routes;
pub mod pairing_store;
pub mod provisioning;
pub mod rpc;
pub mod runtime_reap;
pub mod search_rpc;
pub mod services;
pub(crate) mod session_record_kind;
pub mod sm_stdio;
pub mod socket;
pub(crate) mod spawn_command;
pub mod state;
pub mod tmux;
pub mod watcher;
use std::net::SocketAddr;
use std::sync::Arc;
use tracing::info;
pub use state::DaemonState;
pub async fn run_http(state: Arc<DaemonState>, addr: SocketAddr) -> anyhow::Result<()> {
info!("trusty-mpm daemon starting on {addr}");
let listener = tokio::net::TcpListener::bind(addr).await?;
let rpc = socket::bind(&socket::socket_path()?).await?;
serve_http(state, listener, rpc).await
}
pub async fn serve_http(
state: Arc<DaemonState>,
listener: tokio::net::TcpListener,
rpc: socket::BoundSocket,
) -> anyhow::Result<()> {
serve_with_shutdown(state, listener, rpc, trusty_common::shutdown_signal()).await
}
fn host_state_refusal(state: &DaemonState) -> Option<String> {
crate::core::host_state_gate::host_state_access_for_root(state.framework_root()).skip_reason()
}
pub async fn serve_with_shutdown(
state: Arc<DaemonState>,
listener: tokio::net::TcpListener,
rpc: socket::BoundSocket,
shutdown: impl std::future::Future<Output = ()> + Send + 'static,
) -> anyhow::Result<()> {
match crate::core::host_state_gate::host_state_access().skip_reason() {
Some(reason) => tracing::warn!(
"tmux and host-process discovery are DISABLED for this daemon — {reason}"
),
None if tmux::TmuxDriver::is_available() => info!("tmux control model available"),
None => info!("tmux not found — sessions will need the PTY or SDK control model"),
}
match search_rpc::search_socket() {
Ok(socket) => info!("trusty-search at {}", socket.display()),
Err(e) => tracing::warn!("cannot resolve the trusty-search socket: {e:#}"),
}
let discovered = discovery::discover_all(&state).await;
if discovered.adopted > 0 {
info!(
"auto-discovered {} Claude Code session(s)",
discovered.adopted
);
}
let cancel = tokio_util::sync::CancellationToken::new();
let fw = watcher::FileWatcher::new(Arc::clone(&state));
tokio::spawn(fw.spawn(cancel.child_token()));
managed_routes::auto_relaunch::install_auto_relauncher(&state).await;
crate::runtime::launch_spec::reap_orphans();
tokio::spawn(reap_loop(Arc::clone(&state), cancel.child_token()));
spawn_idle_reaper_if_enabled(Arc::clone(&state), cancel.child_token()).await;
if orphan_gc_enabled() {
tokio::spawn(orphan_gc_loop(Arc::clone(&state), cancel.child_token()));
} else {
info!("orphan-GC disabled via TRUSTY_MPM_ORPHAN_GC");
}
services::merged_pr_reclaim::spawn_if_enabled(Arc::clone(&state), cancel.child_token());
services::slot_pool_evict::spawn_if_enabled(Arc::clone(&state), cancel.child_token());
project_adoption::spawn_startup_marker_migration(&state);
{
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
let framework_root = crate::core::paths::FrameworkPaths::default().root;
let tt_config = crate::core::trusty_tools_config::TrustyToolsConfig::load();
match log_drain::should_spawn(&tt_config, &home) {
Ok(true) => {
tokio::spawn(log_drain::log_drain_loop(
framework_root,
home,
cancel.child_token(),
));
}
Ok(false) => info!("log drain disabled (no `log_drain:` section, or enabled: false)"),
Err(reason) => {
tracing::error!("log drain NOT started — invalid `log_drain:` config: {reason}");
let dir = log_drain::state_dir(&framework_root);
log_drain::save_status(&dir, &log_drain::config_error_status(&reason));
}
}
}
managed_routes::inproject_hygiene_sweep::spawn_if_enabled(Arc::clone(&state));
let app = api::origin_guard::guard_router(
api::router(Arc::clone(&state)),
trusty_common::server::SelfOrigins::default(),
);
info!(
"daemon listening (trusty-mpm v{} pid {}); press Ctrl-C to stop",
env!("CARGO_PKG_VERSION"),
std::process::id()
);
let drain = tokio_util::sync::CancellationToken::new();
let signal_watcher = drain.clone();
tokio::spawn(async move {
shutdown.await;
signal_watcher.cancel();
});
let rpc_drain = drain.clone();
let rpc_state = Arc::clone(&state);
let rpc_task = tokio::spawn(async move {
socket::serve_until_shutdown(rpc, rpc_state, rpc_drain.cancelled_owned()).await;
});
let http_drain = drain.clone();
let reaper_state = Arc::clone(&state);
let http_result = axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.with_graceful_shutdown(async move {
http_drain.cancelled_owned().await;
cancel.cancel();
let mgr = reaper_state.session_manager().await;
mgr.shutdown().await;
reap_all_live_sessions(reaper_state).await;
})
.await;
drain.cancel();
if let Err(e) = rpc_task.await {
tracing::warn!(error = %e, "the rpc serve task did not exit cleanly");
}
http_result?;
Ok(())
}
async fn reap_all_live_sessions(state: Arc<DaemonState>) {
let names: std::collections::HashSet<String> = state
.list_sessions()
.into_iter()
.map(|s| s.tmux_name)
.collect();
let skipped = legacy_reap::skip_legacy_names(names);
info!("graceful shutdown: left {skipped} legacy session(s) running (#9101)");
}
async fn spawn_idle_reaper_if_enabled(
state: Arc<DaemonState>,
cancel: tokio_util::sync::CancellationToken,
) {
let cfg = crate::core::config::MpmConfig::load_default().idle_auto_stop;
if !cfg.enabled {
tracing::debug!("idle-reaper: disabled (idle_auto_stop.enabled = false); skipping");
return;
}
info!(
dry_run = cfg.dry_run,
poll_secs = cfg.poll_interval_secs,
idle_threshold = cfg.idle_consecutive_threshold,
done_threshold = cfg.done_consecutive_threshold,
"idle-reaper: enabled; spawning background loop{}",
if cfg.dry_run {
" (report-only — set idle_auto_stop.dry_run=false to enact teardown)"
} else {
""
}
);
let mgr = state.session_manager().await;
let provider = Arc::new(DaemonVerdictProvider {
mgr: Arc::clone(&mgr),
state,
});
tokio::spawn(idle_reaper::idle_reaper_loop(mgr, cfg, provider, cancel));
}
struct DaemonVerdictProvider {
mgr: Arc<crate::session_manager::SessionManager>,
state: Arc<DaemonState>,
}
impl idle_reaper::IdleVerdictProvider for DaemonVerdictProvider {
async fn verdict(&self, session_name: &str) -> Option<String> {
use crate::activity::ActivityState;
let pane_text = self
.mgr
.capture_pane_by_tmux_name(session_name, 300)
.await?;
let monitor = self.state.activity_monitor();
match monitor.check(session_name, &pane_text).await {
Ok(result) => {
let s = match result.verdict.state {
ActivityState::Idle => "idle",
ActivityState::Done => "done",
ActivityState::Working => "working",
ActivityState::BlockedOnPermission => "blocked-on-permission",
ActivityState::Errored => "errored",
ActivityState::Unknown => "unknown",
};
Some(s.to_string())
}
Err(e) => {
tracing::debug!(name = %session_name, "idle-reaper: activity check failed: {e}");
None
}
}
}
}
pub(crate) const REAP_INTERVAL_SECS: u64 = 60;
async fn reap_loop(state: Arc<DaemonState>, cancel: tokio_util::sync::CancellationToken) {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(REAP_INTERVAL_SECS));
loop {
tokio::select! {
_ = cancel.cancelled() => {
info!("reap_loop: cancel signal received; exiting");
break;
}
_ = tick.tick() => {
crate::daemon::services::stop_spool_drain::drain_unposted_stops(&state).await;
let sweep = state.sweep_delegations();
if sweep.staled > 0 || sweep.evicted > 0 {
info!(
"delegations: {} marked stale (no terminal signal), {} evicted (#2864)",
sweep.staled, sweep.evicted
);
}
if let Some(reason) = host_state_refusal(&state) {
tracing::warn!("reap_loop: skipped — {reason}");
} else if let Ok(driver) = tmux::TmuxDriver::discover() {
let result = state.reap_dead_sessions(&driver);
if result.reaped > 0 {
info!("reaped {} dead session(s)", result.reaped);
}
if result.stopped > 0 {
info!(
"marked {} session(s) stopped (claude process exited)",
result.stopped
);
}
state.reap_dead_managed_sessions(&driver).await;
let mgr = state.session_manager().await;
let stopped = runtime_reap::reap_runtime_exited_managed(
&mgr,
&driver,
&orphan_gc::ProcessTreeProbe,
)
.await;
if stopped > 0 {
info!(
"marked {stopped} managed session(s) stopped (runtime process exited, #1814)"
);
}
}
}
}
}
}
const ORPHAN_GC_INTERVAL_SECS: u64 = 60;
fn orphan_gc_enabled() -> bool {
parse_orphan_gc_enabled(std::env::var("TRUSTY_MPM_ORPHAN_GC").ok().as_deref())
}
fn parse_orphan_gc_enabled(raw: Option<&str>) -> bool {
match raw {
Some(v) => {
let v = v.trim().to_ascii_lowercase();
!matches!(v.as_str(), "0" | "false" | "off" | "no")
}
None => true,
}
}
fn orphan_gc_interval_secs() -> u64 {
parse_orphan_gc_interval(
std::env::var("TRUSTY_MPM_ORPHAN_GC_INTERVAL_SECS")
.ok()
.as_deref(),
)
}
fn parse_orphan_gc_interval(raw: Option<&str>) -> u64 {
raw.and_then(|v| v.trim().parse::<u64>().ok())
.filter(|n| *n > 0)
.unwrap_or(ORPHAN_GC_INTERVAL_SECS)
}
async fn orphan_gc_loop(state: Arc<DaemonState>, cancel: tokio_util::sync::CancellationToken) {
let interval_secs = orphan_gc_interval_secs();
info!(
interval_secs,
"orphan-GC enabled; running startup sweep then periodic reconciliation"
);
let mut gc = orphan_gc::OrphanGc::from_env();
let mut pid_gc = crate::core::pid_registry::PidOrphanGc::new();
let mut retention_gc = crate::session_manager::RetentionDebounce::new();
let probe = orphan_gc::ProcessTreeProbe;
let mut tick = tokio::time::interval(std::time::Duration::from_secs(interval_secs));
loop {
tokio::select! {
_ = cancel.cancelled() => {
info!("orphan_gc_loop: cancel signal received; exiting");
break;
}
_ = tick.tick() => {
{
let mgr = state.session_manager().await;
match mgr.sweep_default_retention(&mut retention_gc).await {
Ok(o) if !o.is_empty() => info!(
stamped = o.stamped,
evicted = o.evicted.len(),
"record retention sweep"
),
Ok(_) => {}
Err(e) => tracing::warn!("record retention sweep failed: {e}"),
}
}
if let Some(reason) = host_state_refusal(&state) {
tracing::warn!("orphan-GC: skipped — {reason}");
continue;
}
let sessions_dir =
crate::core::session_store::sessions_root_under(state.framework_root());
match crate::core::session_store::reap_empty_session_dirs_at(&sessions_dir) {
Ok(sweep) if !sweep.is_empty() => info!(
removed = sweep.removed,
skipped = sweep.skipped,
failed = sweep.failed,
"empty session-dir sweep"
),
Ok(_) => {}
Err(e) => tracing::warn!("empty session-dir sweep failed: {e}"),
}
let Ok(driver) = tmux::TmuxDriver::discover() else {
continue;
};
let panes = match driver.list_managed_panes() {
Ok(p) => p,
Err(e) => {
tracing::warn!("orphan-GC: list_managed_panes failed: {e}");
continue;
}
};
let tracked = state.gather_tracked_names().await;
let mgr = state.session_manager().await;
let mtmux = mgr.tmux_driver();
let reaped =
orphan_gc::run_sweep_fs(&mut gc, &panes, &tracked, &probe, mtmux.as_ref());
if reaped > 0 {
info!("orphan-GC reaped {reaped} orphaned managed session(s)");
}
let live_ids = state.gather_live_session_ids().await;
let pid_outcome = state.pid_registry().sweep_orphans(
&live_ids,
&crate::core::pid_registry::OsProcessProbe,
&mut pid_gc,
);
if pid_outcome.terminated > 0
|| pid_outcome.removed_stale > 0
|| pid_outcome.deferred > 0
{
info!(
terminated = pid_outcome.terminated,
removed_stale = pid_outcome.removed_stale,
deferred = pid_outcome.deferred,
"orphan-GC PID-file sweep cleaned up orphaned claude process(es)"
);
}
let max_age = chrono::Duration::hours(crate::session_manager::MAX_EPHEMERAL_AGE_HOURS);
match mgr.reap_aged_ephemeral(max_age).await {
Ok(n) if n > 0 => info!("ephemeral auto-reap decommissioned {n} stale session(s)"),
Ok(_) => {}
Err(e) => tracing::warn!("ephemeral auto-reap failed: {e}"),
}
let repos_root = managed_routes::inproject::repos_root();
let adopted = crate::project::adopted_anchors_under(state.framework_root());
match mgr.reap_orphaned_worktrees(&repos_root, &adopted).await {
Ok(outcome) => {
if !outcome.removed.is_empty() {
info!(
"orphan-GC reaped {} orphaned worktree dir(s)",
outcome.removed.len()
);
}
if !outcome.owner_unknown.is_empty() {
info!(
"orphan-GC left {} owner-unknown worktree dir(s) untouched \
(run `tm doctor` to review, #3649)",
outcome.owner_unknown.len()
);
}
if !outcome.agent_owned.is_empty() {
info!(
"orphan-GC left {} agent-owned worktree dir(s) to their agents' \
exits (#4311)",
outcome.agent_owned.len()
);
}
for dirt in &outcome.skipped_dirty {
tracing::warn!(
path = %dirt.path.display(),
reason = %dirt.reason,
"orphan-GC left a worktree with unsaved work untouched (#4091)"
);
}
}
Err(e) => tracing::warn!("orphan-GC worktree sweep failed: {e}"),
}
match mgr.reap_orphaned_search_indexes().await {
Ok(removed) if !removed.is_empty() => {
info!(
"orphan-GC removed {} orphaned trusty-search index(es)",
removed.len()
);
}
Ok(_) => {}
Err(e) => tracing::warn!("orphan-GC search-index sweep failed: {e}"),
}
}
}
}
}
pub async fn run_mcp(state: Arc<DaemonState>) -> anyhow::Result<()> {
info!("trusty-mpm MCP server starting on stdio");
let backend = mcp_backend::StateBackend::new(state);
trusty_mcp::run_stdio_loop(move |req| {
let backend = backend.clone();
async move { crate::mcp::dispatch(&backend, req).await }
})
.await
}
#[cfg(test)]
mod shutdown_reaper_tests {
use super::{DaemonState, host_state_refusal, reap_all_live_sessions, reap_loop};
use std::sync::Arc;
fn scratch_root_state() -> (Arc<DaemonState>, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("scratch framework root");
let state = Arc::new(DaemonState::with_root(dir.path().to_path_buf()));
(state, dir)
}
struct HomeGuard(Option<String>);
impl Drop for HomeGuard {
fn drop(&mut self) {
match self.0 {
Some(ref p) => unsafe { std::env::set_var("HOME", p) },
None => unsafe { std::env::remove_var("HOME") },
}
}
}
fn set_home(home: &std::path::Path) -> HomeGuard {
let prior = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", home) };
HomeGuard(prior)
}
fn dead_session() -> crate::core::session::Session {
use crate::core::session::{ControlModel, Session, SessionId, SessionStatus};
let mut s = Session::new(SessionId::new(), "/tmp/p", ControlModel::Tmux, None);
s.tmux_name = format!("tmpm-6348-absent-{}", std::process::id());
s.status = SessionStatus::Active;
s
}
#[test]
fn host_state_refusal_blocks_a_scratch_framework_root() {
let (state, dir) = scratch_root_state();
let reason = host_state_refusal(&state).expect("a scratch framework root must be refused");
assert!(
reason.contains(crate::core::host_state_gate::ALLOW_HOST_STATE_ENV),
"a refusal an operator cannot lift is a dead end; got: {reason}"
);
if crate::core::host_state_gate::host_state_access().is_allowed() {
assert!(
reason.contains(&dir.path().display().to_string()),
"under a real $HOME the refusal must be the DATA-ROOT arm, naming {}; got: {reason}",
dir.path().display()
);
}
}
#[tokio::test]
async fn reap_loop_leaves_the_registry_alone_on_a_scratch_framework_root() {
let (state, _dir) = scratch_root_state();
let session = dead_session();
let id = session.id;
state.register_session(session);
let cancel = tokio_util::sync::CancellationToken::new();
let handle = tokio::spawn(reap_loop(Arc::clone(&state), cancel.child_token()));
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
cancel.cancel();
handle.await.expect("reap_loop must not panic");
assert!(
state.session(id).is_some(),
"a refused sweep must reap nothing — the registry entry was removed"
);
}
#[tokio::test]
async fn reap_all_live_sessions_refuses_on_a_scratch_framework_root() {
let (state, _dir) = scratch_root_state();
let session = dead_session();
let id = session.id;
state.register_session(session);
reap_all_live_sessions(Arc::clone(&state)).await;
assert!(
state.session(id).is_some(),
"the shutdown reap must not mutate the registry"
);
assert!(
host_state_refusal(&state).is_some(),
"the fixture must actually be in the refused quadrant"
);
}
#[tokio::test]
async fn orphan_gc_loop_ticks_cleanly_on_a_scratch_framework_root() {
let (state, _dir) = scratch_root_state();
let session = dead_session();
let id = session.id;
state.register_session(session);
let cancel = tokio_util::sync::CancellationToken::new();
let handle = tokio::spawn(super::orphan_gc_loop(
Arc::clone(&state),
cancel.child_token(),
));
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
cancel.cancel();
handle.await.expect("orphan_gc_loop must not panic");
assert!(
state.session(id).is_some(),
"a refused sweep must leave the registry alone"
);
}
#[tokio::test]
#[serial_test::serial]
async fn orphan_gc_loop_leaves_the_home_session_dirs_alone_on_a_scratch_root() {
let home_dir = tempfile::tempdir().expect("scratch home");
let home = home_dir.path().to_path_buf();
let _home = set_home(&home);
let (state, dir) = scratch_root_state();
assert!(
host_state_refusal(&state).is_some(),
"the fixture must be in the refused quadrant for this to discriminate"
);
assert_ne!(
dir.path(),
home.join(".trusty-mpm"),
"the scratch root must not BE the home root, or the test proves nothing"
);
let canary = crate::core::session_store::sessions_root_in(&home)
.join(uuid::Uuid::new_v4().to_string());
std::fs::create_dir_all(&canary).expect("plant the canary");
let cancel = tokio_util::sync::CancellationToken::new();
let handle = tokio::spawn(super::orphan_gc_loop(
Arc::clone(&state),
cancel.child_token(),
));
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1);
let mut survived = true;
while std::time::Instant::now() < deadline {
if !canary.is_dir() {
survived = false;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
cancel.cancel();
handle.await.expect("orphan_gc_loop must not panic");
let _ = std::fs::remove_dir(&canary);
assert!(
survived,
"a scratch-rooted daemon deleted {} out of the home session tree",
canary.display()
);
}
#[tokio::test]
async fn reap_all_live_sessions_is_safe_when_empty() {
let state: Arc<DaemonState> = DaemonState::shared();
reap_all_live_sessions(state).await;
}
#[tokio::test]
async fn cancel_token_stops_reap_loop_cleanly() {
let state: Arc<DaemonState> = DaemonState::shared();
let cancel = tokio_util::sync::CancellationToken::new();
let token = cancel.clone();
let handle = tokio::spawn(reap_loop(state, token));
cancel.cancel();
handle
.await
.expect("reap_loop task must not panic after cancellation");
}
}
#[cfg(test)]
mod orphan_gc_config_tests {
use super::{ORPHAN_GC_INTERVAL_SECS, parse_orphan_gc_enabled, parse_orphan_gc_interval};
#[test]
fn parse_orphan_gc_enabled_default_and_overrides() {
assert!(parse_orphan_gc_enabled(None), "unset must default ON");
for off in ["0", "false", "off", "no", "OFF", " false "] {
assert!(!parse_orphan_gc_enabled(Some(off)), "{off:?} must disable");
}
for on in ["1", "true", "yes", "anything", " "] {
assert!(
parse_orphan_gc_enabled(Some(on)),
"{on:?} must keep enabled"
);
}
}
#[test]
fn parse_orphan_gc_interval_override() {
assert_eq!(parse_orphan_gc_interval(None), ORPHAN_GC_INTERVAL_SECS);
assert_eq!(parse_orphan_gc_interval(Some("30")), 30);
assert_eq!(parse_orphan_gc_interval(Some(" 45 ")), 45);
for bad in ["0", "nonsense", "-5", ""] {
assert_eq!(
parse_orphan_gc_interval(Some(bad)),
ORPHAN_GC_INTERVAL_SECS,
"{bad:?}"
);
}
}
}