use std::collections::HashMap;
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IdleNudgeConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "IdleNudgeConfig::default_max_nudges_per_session")]
pub max_nudges_per_session: u32,
#[serde(default = "IdleNudgeConfig::default_cooldown_secs")]
pub cooldown_secs: u64,
#[serde(default = "IdleNudgeConfig::default_message")]
pub message: String,
}
impl IdleNudgeConfig {
fn default_max_nudges_per_session() -> u32 {
2
}
fn default_cooldown_secs() -> u64 {
300
}
fn default_message() -> String {
DEFAULT_NUDGE_MESSAGE.to_string()
}
}
impl Default for IdleNudgeConfig {
fn default() -> Self {
Self {
enabled: false,
max_nudges_per_session: Self::default_max_nudges_per_session(),
cooldown_secs: Self::default_cooldown_secs(),
message: Self::default_message(),
}
}
}
pub const DEFAULT_NUDGE_MESSAGE: &str = "[trusty-mpm auto-nudge #2621] Your last turn ended with \
language that parks the task waiting on a background monitor/notification, but a stopped agent \
cannot be re-woken by the thing it is waiting on. Resume NOW: block in the FOREGROUND (re-run the \
gate, or `gh pr checks <n> --watch`) and do not end your turn until it actually completes. If you \
are genuinely blocked on a human decision, say so explicitly instead.";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct NudgeRecord {
pub count: u32,
pub last_nudge_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkipReason {
Disabled,
NotParking,
HumanWait,
HasLiveChildren,
CapExhausted,
WithinCooldown,
}
impl SkipReason {
pub fn tag(self) -> &'static str {
match self {
SkipReason::Disabled => "disabled",
SkipReason::NotParking => "not_parking",
SkipReason::HumanWait => "human_wait",
SkipReason::HasLiveChildren => "has_live_children",
SkipReason::CapExhausted => "cap_exhausted",
SkipReason::WithinCooldown => "within_cooldown",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NudgeDecision {
Nudge,
Skip(SkipReason),
}
impl NudgeDecision {
pub fn should_nudge(self) -> bool {
matches!(self, NudgeDecision::Nudge)
}
}
pub fn decide_nudge(
cfg: &IdleNudgeConfig,
phrase: Option<&str>,
has_live_children: bool,
record: &NudgeRecord,
now: DateTime<Utc>,
) -> NudgeDecision {
if !cfg.enabled {
return NudgeDecision::Skip(SkipReason::Disabled);
}
let Some(phrase) = phrase else {
return NudgeDecision::Skip(SkipReason::NotParking);
};
if !crate::core::idle_parking::is_nudge_eligible(phrase) {
return NudgeDecision::Skip(SkipReason::HumanWait);
}
if has_live_children {
return NudgeDecision::Skip(SkipReason::HasLiveChildren);
}
if record.count >= cfg.max_nudges_per_session {
return NudgeDecision::Skip(SkipReason::CapExhausted);
}
if let Some(last) = record.last_nudge_at {
let elapsed = now.signed_duration_since(last);
if elapsed < Duration::seconds(cfg.cooldown_secs as i64) {
return NudgeDecision::Skip(SkipReason::WithinCooldown);
}
}
NudgeDecision::Nudge
}
#[derive(Debug, Default)]
pub struct NudgeLedger {
records: HashMap<Uuid, NudgeRecord>,
}
impl NudgeLedger {
pub fn new() -> Self {
Self::default()
}
pub fn snapshot(&self, session: Uuid) -> NudgeRecord {
self.records.get(&session).copied().unwrap_or_default()
}
pub fn record_nudge(&mut self, session: Uuid, now: DateTime<Utc>) {
let entry = self.records.entry(session).or_default();
entry.count = entry.count.saturating_add(1);
entry.last_nudge_at = Some(now);
}
pub fn prune(&mut self, live: &std::collections::HashSet<Uuid>) {
self.records.retain(|id, _| live.contains(id));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn enabled_cfg() -> IdleNudgeConfig {
IdleNudgeConfig {
enabled: true,
max_nudges_per_session: 2,
cooldown_secs: 300,
message: "nudge".into(),
}
}
#[test]
fn default_message_mentions_foreground() {
let m = DEFAULT_NUDGE_MESSAGE.to_lowercase();
assert!(m.contains("foreground"), "must tell the agent to block");
assert!(m.contains("#2621"), "must be attributable to the feature");
}
const MACHINE_PHRASE: &str = "i'll wait for the";
const HUMAN_PHRASE: &str = "let me know once";
#[test]
fn skip_reason_tags_are_stable() {
assert_eq!(SkipReason::Disabled.tag(), "disabled");
assert_eq!(SkipReason::NotParking.tag(), "not_parking");
assert_eq!(SkipReason::HumanWait.tag(), "human_wait");
assert_eq!(SkipReason::HasLiveChildren.tag(), "has_live_children");
assert_eq!(SkipReason::CapExhausted.tag(), "cap_exhausted");
assert_eq!(SkipReason::WithinCooldown.tag(), "within_cooldown");
}
#[test]
fn disabled_skips() {
let mut cfg = enabled_cfg();
cfg.enabled = false;
let d = decide_nudge(
&cfg,
Some(MACHINE_PHRASE),
false,
&NudgeRecord::default(),
Utc::now(),
);
assert_eq!(d, NudgeDecision::Skip(SkipReason::Disabled));
assert!(!d.should_nudge());
}
#[test]
fn not_parking_skips() {
let cfg = enabled_cfg();
let d = decide_nudge(&cfg, None, false, &NudgeRecord::default(), Utc::now());
assert_eq!(d, NudgeDecision::Skip(SkipReason::NotParking));
}
#[test]
fn human_wait_skips() {
let cfg = enabled_cfg();
let d = decide_nudge(
&cfg,
Some(HUMAN_PHRASE),
false,
&NudgeRecord::default(),
Utc::now(),
);
assert_eq!(d, NudgeDecision::Skip(SkipReason::HumanWait));
}
#[test]
fn live_children_skips() {
let cfg = enabled_cfg();
let d = decide_nudge(
&cfg,
Some(MACHINE_PHRASE),
true,
&NudgeRecord::default(),
Utc::now(),
);
assert_eq!(d, NudgeDecision::Skip(SkipReason::HasLiveChildren));
}
#[test]
fn enabled_and_parking_and_idle_nudges() {
let cfg = enabled_cfg();
let d = decide_nudge(
&cfg,
Some(MACHINE_PHRASE),
false,
&NudgeRecord::default(),
Utc::now(),
);
assert_eq!(d, NudgeDecision::Nudge);
assert!(d.should_nudge());
}
#[test]
fn cap_blocks_after_max() {
let cfg = enabled_cfg(); let now = Utc::now();
let rec = NudgeRecord {
count: 2,
last_nudge_at: Some(now - Duration::seconds(10_000)),
};
assert_eq!(
decide_nudge(&cfg, Some(MACHINE_PHRASE), false, &rec, now),
NudgeDecision::Skip(SkipReason::CapExhausted)
);
let rec = NudgeRecord {
count: 1,
last_nudge_at: Some(now - Duration::seconds(10_000)),
};
assert_eq!(
decide_nudge(&cfg, Some(MACHINE_PHRASE), false, &rec, now),
NudgeDecision::Nudge
);
}
#[test]
fn cap_of_zero_disables() {
let mut cfg = enabled_cfg();
cfg.max_nudges_per_session = 0;
assert_eq!(
decide_nudge(
&cfg,
Some(MACHINE_PHRASE),
false,
&NudgeRecord::default(),
Utc::now()
),
NudgeDecision::Skip(SkipReason::CapExhausted)
);
}
#[test]
fn cooldown_blocks_within_window() {
let cfg = enabled_cfg();
let now = Utc::now();
let rec = NudgeRecord {
count: 1,
last_nudge_at: Some(now - Duration::seconds(60)),
};
assert_eq!(
decide_nudge(&cfg, Some(MACHINE_PHRASE), false, &rec, now),
NudgeDecision::Skip(SkipReason::WithinCooldown)
);
}
#[test]
fn nudge_allowed_after_cooldown() {
let cfg = enabled_cfg();
let now = Utc::now();
let rec = NudgeRecord {
count: 1,
last_nudge_at: Some(now - Duration::seconds(301)),
};
assert_eq!(
decide_nudge(&cfg, Some(MACHINE_PHRASE), false, &rec, now),
NudgeDecision::Nudge
);
}
#[test]
fn ledger_snapshot_defaults_when_unseen() {
let ledger = NudgeLedger::new();
let rec = ledger.snapshot(Uuid::new_v4());
assert_eq!(rec, NudgeRecord::default());
assert_eq!(rec.count, 0);
assert!(rec.last_nudge_at.is_none());
}
#[test]
fn ledger_record_bumps_count() {
let mut ledger = NudgeLedger::new();
let id = Uuid::new_v4();
let t0 = Utc::now();
ledger.record_nudge(id, t0);
let rec = ledger.snapshot(id);
assert_eq!(rec.count, 1);
assert_eq!(rec.last_nudge_at, Some(t0));
let t1 = t0 + Duration::seconds(500);
ledger.record_nudge(id, t1);
let rec = ledger.snapshot(id);
assert_eq!(rec.count, 2);
assert_eq!(rec.last_nudge_at, Some(t1));
}
#[test]
fn ledger_prune_drops_stale() {
let mut ledger = NudgeLedger::new();
let live_id = Uuid::new_v4();
let stale_id = Uuid::new_v4();
let now = Utc::now();
ledger.record_nudge(live_id, now);
ledger.record_nudge(stale_id, now);
let live = std::collections::HashSet::from([live_id]);
ledger.prune(&live);
assert_eq!(ledger.snapshot(live_id).count, 1, "live entry survives");
assert_eq!(
ledger.snapshot(stale_id),
NudgeRecord::default(),
"stale entry pruned back to default"
);
}
#[test]
fn rails_short_circuit_in_order() {
let cfg = enabled_cfg();
let now = Utc::now();
let exhausted = NudgeRecord {
count: 99,
last_nudge_at: Some(now),
};
let mut disabled = cfg.clone();
disabled.enabled = false;
assert_eq!(
decide_nudge(&disabled, Some(HUMAN_PHRASE), true, &exhausted, now),
NudgeDecision::Skip(SkipReason::Disabled)
);
assert_eq!(
decide_nudge(&cfg, None, true, &exhausted, now),
NudgeDecision::Skip(SkipReason::NotParking)
);
assert_eq!(
decide_nudge(&cfg, Some(HUMAN_PHRASE), true, &exhausted, now),
NudgeDecision::Skip(SkipReason::HumanWait)
);
assert_eq!(
decide_nudge(&cfg, Some(MACHINE_PHRASE), true, &exhausted, now),
NudgeDecision::Skip(SkipReason::HasLiveChildren)
);
}
}