use std::sync::Arc;
use chrono::{DateTime, Utc};
use tracing::{info, warn};
use crate::core::agent::Delegation;
use crate::core::config::MpmConfig;
use crate::core::hook::HookEvent;
use crate::core::idle_nudge::{IdleNudgeConfig, NudgeDecision, NudgeLedger, decide_nudge};
use crate::core::session::SessionId;
use crate::core::sm::control::Submit;
use crate::daemon::state::DaemonState;
use crate::session_manager::{ManagedSessionState, SessionManager};
pub fn has_live_children(delegations: &[Delegation]) -> bool {
delegations.iter().any(|d| d.status.is_live())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NudgeOutcome {
NoManagedSession,
Skipped(crate::core::idle_nudge::SkipReason),
Nudged,
InjectFailed(String),
}
pub async fn run_nudge(
mgr: &SessionManager,
ledger: &parking_lot::Mutex<NudgeLedger>,
delegations: &[Delegation],
cfg: &IdleNudgeConfig,
claude_session_id: &str,
phrase: Option<&str>,
now: DateTime<Utc>,
) -> NudgeOutcome {
let records = mgr.list().await;
let live_ids: std::collections::HashSet<uuid::Uuid> = records.iter().map(|r| r.id.0).collect();
ledger.lock().prune(&live_ids);
let Some(record) = records.iter().find(|r| {
matches!(r.state, ManagedSessionState::Active)
&& r.claude_session_id.as_deref() == Some(claude_session_id)
&& !crate::session_manager::supervisor::skip_protected(r, "idle nudge")
}) else {
return NudgeOutcome::NoManagedSession;
};
let managed_id = record.id;
let live_children = has_live_children(delegations);
let decision = {
let mut guard = ledger.lock();
let snapshot = guard.snapshot(managed_id.0);
let decision = decide_nudge(cfg, phrase, live_children, &snapshot, now);
if decision == NudgeDecision::Nudge {
guard.record_nudge(managed_id.0, now);
}
decision
};
match decision {
NudgeDecision::Skip(reason) => NudgeOutcome::Skipped(reason),
NudgeDecision::Nudge => match mgr.inject(&managed_id, &cfg.message, Submit::Enter).await {
Ok(()) => NudgeOutcome::Nudged,
Err(e) => NudgeOutcome::InjectFailed(e.to_string()),
},
}
}
pub fn spawn_if_parked(
state: &Arc<DaemonState>,
event: HookEvent,
session_id: &str,
payload: &serde_json::Value,
) {
if !matches!(event, HookEvent::Stop | HookEvent::SubagentStop) {
return;
}
if payload
.get("idle_parking")
.and_then(serde_json::Value::as_bool)
!= Some(true)
{
return;
}
let state = Arc::clone(state);
let session_id = session_id.to_string();
let phrase = payload
.get("idle_parking_phrase")
.and_then(serde_json::Value::as_str)
.map(str::to_string);
tokio::spawn(async move {
maybe_nudge_parked_session(&state, &session_id, phrase.as_deref()).await;
});
}
pub async fn maybe_nudge_parked_session(
state: &Arc<DaemonState>,
claude_session_id: &str,
phrase: Option<&str>,
) {
warn!(
target: "trusty_mpm::idle_parking",
session = %claude_session_id,
phrase = phrase.unwrap_or("<unknown>"),
"idle-parking detected on a managed session turn-end (#2621); a stopped agent cannot be re-woken by a self-spawned monitor"
);
let cfg = MpmConfig::load_default().idle_nudge;
if !cfg.enabled {
info!(
target: "trusty_mpm::idle_parking",
session = %claude_session_id,
"auto-nudge disabled ([idle_nudge].enabled = false); surfaced only"
);
return;
}
let delegations = uuid::Uuid::parse_str(claude_session_id)
.map(|u| state.delegations_for(SessionId(u)))
.unwrap_or_default();
let mgr = state.session_manager().await;
let outcome = run_nudge(
&mgr,
state.nudge_ledger(),
&delegations,
&cfg,
claude_session_id,
phrase,
Utc::now(),
)
.await;
match &outcome {
NudgeOutcome::Nudged => info!(
target: "trusty_mpm::idle_parking",
session = %claude_session_id,
"auto-nudged idle-parked managed session (#2621)"
),
NudgeOutcome::Skipped(reason) => info!(
target: "trusty_mpm::idle_parking",
session = %claude_session_id,
reason = reason.tag(),
"auto-nudge skipped (#2621)"
),
NudgeOutcome::NoManagedSession => info!(
target: "trusty_mpm::idle_parking",
session = %claude_session_id,
"auto-nudge skipped: no Active managed session correlates to this Claude session (#2621)"
),
NudgeOutcome::InjectFailed(e) => warn!(
target: "trusty_mpm::idle_parking",
session = %claude_session_id,
error = %e,
"auto-nudge inject failed (#2621)"
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::agent::{Delegation, DelegationStatus, ModelTier};
use crate::core::idle_nudge::SkipReason;
use crate::core::session::SessionId;
use crate::session_manager::{FakeNoopTmuxDriver, ManagedSessionId};
use tempfile::TempDir;
const MACHINE_PHRASE: &str = "i'll wait for the";
const HUMAN_PHRASE: &str = "let me know once";
fn enabled_cfg() -> IdleNudgeConfig {
IdleNudgeConfig {
enabled: true,
max_nudges_per_session: 2,
cooldown_secs: 300,
message: "resume in the foreground now".into(),
}
}
fn delegation_with(status: DelegationStatus) -> Delegation {
let mut d = Delegation::new(
SessionId::new(),
None,
"rust-engineer",
ModelTier::Sonnet,
"x",
);
d.status = status;
d
}
#[test]
fn has_live_children_true_for_running() {
assert!(has_live_children(&[delegation_with(
DelegationStatus::Running
)]));
assert!(has_live_children(&[delegation_with(
DelegationStatus::Queued
)]));
}
#[test]
fn has_live_children_false_when_terminal() {
assert!(!has_live_children(&[
delegation_with(DelegationStatus::Completed),
delegation_with(DelegationStatus::Failed),
delegation_with(DelegationStatus::Cancelled),
]));
}
#[test]
fn has_live_children_false_when_empty() {
assert!(!has_live_children(&[]));
}
async fn manager_with_active_correlated(
dir: &TempDir,
claude_id: &str,
) -> (Arc<SessionManager>, ManagedSessionId) {
let mgr = SessionManager::new(dir.path(), Arc::new(FakeNoopTmuxDriver))
.await
.expect("manager");
let rec = mgr
.create(
"parked task".into(),
Some(dir.path().to_path_buf()),
None,
None,
None,
None,
)
.await
.expect("create");
mgr.stop(&rec.id).await.expect("stop");
mgr.resume(&rec.id).await.expect("resume");
mgr.set_claude_session_id(&rec.id, claude_id)
.await
.expect("set claude id");
(Arc::new(mgr), rec.id)
}
#[tokio::test]
async fn run_nudge_injects_when_idle_and_enabled() {
let dir = TempDir::new().unwrap();
let claude_id = uuid::Uuid::new_v4().to_string();
let (mgr, id) = manager_with_active_correlated(&dir, &claude_id).await;
let ledger = parking_lot::Mutex::new(NudgeLedger::new());
let cfg = enabled_cfg();
let now = Utc::now();
let outcome = run_nudge(
&mgr,
&ledger,
&[],
&cfg,
&claude_id,
Some(MACHINE_PHRASE),
now,
)
.await;
assert_eq!(outcome, NudgeOutcome::Nudged);
assert_eq!(ledger.lock().snapshot(id.0).count, 1);
}
#[tokio::test]
async fn idle_nudge_never_types_into_a_supervisor_pane() {
let dir = TempDir::new().unwrap();
let claude_id = uuid::Uuid::new_v4().to_string();
let (mgr, id) = manager_with_active_correlated(&dir, &claude_id).await;
let mut record = mgr.get(&id).await.expect("record");
record.kind = crate::session_manager::SessionKind::Supervisor;
mgr.store.write().await.upsert(record).await.expect("seed");
let ledger = parking_lot::Mutex::new(NudgeLedger::new());
let outcome = run_nudge(
&mgr,
&ledger,
&[],
&enabled_cfg(),
&claude_id,
Some(MACHINE_PHRASE),
Utc::now(),
)
.await;
assert_eq!(outcome, NudgeOutcome::NoManagedSession);
assert_eq!(ledger.lock().snapshot(id.0).count, 0);
}
#[tokio::test]
async fn run_nudge_skips_when_disabled() {
let dir = TempDir::new().unwrap();
let claude_id = uuid::Uuid::new_v4().to_string();
let (mgr, id) = manager_with_active_correlated(&dir, &claude_id).await;
let ledger = parking_lot::Mutex::new(NudgeLedger::new());
let mut cfg = enabled_cfg();
cfg.enabled = false;
let outcome = run_nudge(
&mgr,
&ledger,
&[],
&cfg,
&claude_id,
Some(MACHINE_PHRASE),
Utc::now(),
)
.await;
assert_eq!(outcome, NudgeOutcome::Skipped(SkipReason::Disabled));
assert_eq!(
ledger.lock().snapshot(id.0).count,
0,
"no delivery recorded"
);
}
#[tokio::test]
async fn run_nudge_skips_when_live_children() {
let dir = TempDir::new().unwrap();
let claude_id = uuid::Uuid::new_v4().to_string();
let (mgr, _id) = manager_with_active_correlated(&dir, &claude_id).await;
let ledger = parking_lot::Mutex::new(NudgeLedger::new());
let cfg = enabled_cfg();
let children = [delegation_with(DelegationStatus::Running)];
let outcome = run_nudge(
&mgr,
&ledger,
&children,
&cfg,
&claude_id,
Some(MACHINE_PHRASE),
Utc::now(),
)
.await;
assert_eq!(outcome, NudgeOutcome::Skipped(SkipReason::HasLiveChildren));
}
#[tokio::test]
async fn run_nudge_skips_human_wait_phrase() {
let dir = TempDir::new().unwrap();
let claude_id = uuid::Uuid::new_v4().to_string();
let (mgr, id) = manager_with_active_correlated(&dir, &claude_id).await;
let ledger = parking_lot::Mutex::new(NudgeLedger::new());
let cfg = enabled_cfg();
let outcome = run_nudge(
&mgr,
&ledger,
&[],
&cfg,
&claude_id,
Some(HUMAN_PHRASE),
Utc::now(),
)
.await;
assert_eq!(outcome, NudgeOutcome::Skipped(SkipReason::HumanWait));
assert_eq!(
ledger.lock().snapshot(id.0).count,
0,
"a human-wait skip must not consume a nudge budget slot"
);
}
#[tokio::test]
async fn run_nudge_no_managed_session() {
let dir = TempDir::new().unwrap();
let (mgr, _id) =
manager_with_active_correlated(&dir, &uuid::Uuid::new_v4().to_string()).await;
let ledger = parking_lot::Mutex::new(NudgeLedger::new());
let cfg = enabled_cfg();
let outcome = run_nudge(
&mgr,
&ledger,
&[],
&cfg,
&uuid::Uuid::new_v4().to_string(),
Some(MACHINE_PHRASE),
Utc::now(),
)
.await;
assert_eq!(outcome, NudgeOutcome::NoManagedSession);
}
#[tokio::test]
async fn run_nudge_respects_cap() {
let dir = TempDir::new().unwrap();
let claude_id = uuid::Uuid::new_v4().to_string();
let (mgr, id) = manager_with_active_correlated(&dir, &claude_id).await;
let ledger = parking_lot::Mutex::new(NudgeLedger::new());
let mut cfg = enabled_cfg();
cfg.max_nudges_per_session = 1;
cfg.cooldown_secs = 0;
let now = Utc::now();
assert_eq!(
run_nudge(
&mgr,
&ledger,
&[],
&cfg,
&claude_id,
Some(MACHINE_PHRASE),
now
)
.await,
NudgeOutcome::Nudged
);
let outcome = run_nudge(
&mgr,
&ledger,
&[],
&cfg,
&claude_id,
Some(MACHINE_PHRASE),
now + chrono::Duration::seconds(1),
)
.await;
assert_eq!(outcome, NudgeOutcome::Skipped(SkipReason::CapExhausted));
assert_eq!(ledger.lock().snapshot(id.0).count, 1, "cap holds at 1");
}
#[tokio::test]
async fn run_nudge_prunes_stale_ledger_entries() {
let dir = TempDir::new().unwrap();
let claude_id = uuid::Uuid::new_v4().to_string();
let (mgr, id) = manager_with_active_correlated(&dir, &claude_id).await;
let ledger = parking_lot::Mutex::new(NudgeLedger::new());
let stale_id = uuid::Uuid::new_v4();
let now = Utc::now();
ledger.lock().record_nudge(stale_id, now);
ledger
.lock()
.record_nudge(id.0, now - chrono::Duration::seconds(10_000));
let cfg = enabled_cfg();
let outcome = run_nudge(
&mgr,
&ledger,
&[],
&cfg,
&claude_id,
Some(MACHINE_PHRASE),
now,
)
.await;
assert_eq!(outcome, NudgeOutcome::Nudged);
let guard = ledger.lock();
assert_eq!(
guard.snapshot(stale_id),
crate::core::idle_nudge::NudgeRecord::default(),
"stale ledger entry for a non-existent session must be pruned"
);
assert_eq!(
guard.snapshot(id.0).count,
2,
"the real session's record survives pruning and reflects this nudge"
);
}
}