Skip to main content

mur_common/agent/
reload.rs

1//! Which `profile.yaml` edits a running agent may pick up without a restart.
2//!
3//! One classifier for both sides: the runtime decides what to apply, the CLI
4//! decides whether to say "next turn" or "restart". Two hand-kept lists would
5//! drift, and the side that drifted would lie to the user.
6//!
7//! The comparison walks the serialized profile instead of naming fields, so a
8//! field added to [`AgentProfile`] later lands in `cold` by default. That is
9//! the safe direction: an unclassified field can only ever cost a restart, it
10//! can never be hot-applied by accident — which matters because the locked set
11//! includes entitlements and the sandbox.
12
13use super::AgentProfile;
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16
17/// Dotted paths that take effect on the next turn.
18///
19/// `addons` is here for the skill gate; its MCP half is not hot (MCP servers
20/// are spawned at boot), so a changed `addons` is reported in both lists.
21pub const HOT_PATHS: &[&str] = &[
22    "limits",
23    "disabled_skills",
24    "addons",
25    "hitl.autonomy",
26    "lifecycle.schedule",
27    "lifecycle.idle_triggers",
28];
29
30/// Top-level keys whose hot paths are nested, so the walk descends into them.
31const NESTED_PARENTS: &[&str] = &["hitl", "lifecycle"];
32
33/// Bookkeeping stamps that change on every CLI write and mean nothing at runtime.
34const IGNORED_PATHS: &[&str] = &["updated_at"];
35
36/// The result of comparing two profiles.
37#[derive(Debug, Clone, Default, PartialEq, Eq)]
38pub struct ReloadDiff {
39    /// Changed paths a running agent applies on its next turn.
40    pub hot: Vec<String>,
41    /// Changed paths that need `mur agent restart`.
42    pub cold: Vec<String>,
43}
44
45impl ReloadDiff {
46    pub fn is_empty(&self) -> bool {
47        self.hot.is_empty() && self.cold.is_empty()
48    }
49
50    /// True when every change is hot — nothing waits for a restart.
51    pub fn hot_only(&self) -> bool {
52        self.cold.is_empty()
53    }
54}
55
56/// File in `agent_home` where a running agent records its reload state.
57/// Written by the runtime (agent_home stays writable), read by `doctor`.
58pub const RELOAD_STATE_FILE: &str = "reload_state.json";
59
60/// What a running agent last did with its `profile.yaml`.
61#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
62pub struct ReloadState {
63    /// Successful reloads since boot.
64    pub generation: u64,
65    /// Why the newest `profile.yaml` on disk is not the one being served.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub last_reload_error: Option<String>,
68    /// Same, for the global limits scope (`limits/global.yaml`, or the legacy
69    /// `config.yaml limits:` block before #1794). The field keeps its name so
70    /// doctor still reads state written by an agent started before the move.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub config_limits_error: Option<String>,
73    /// Changed paths that only take effect after `mur agent restart`.
74    #[serde(default, skip_serializing_if = "Vec::is_empty")]
75    pub pending_cold_restart: Vec<String>,
76}
77
78impl ReloadState {
79    /// Atomic write: temp file + rename.
80    pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> {
81        let tmp = path.with_extension("json.tmp");
82        std::fs::write(&tmp, serde_json::to_vec_pretty(self)?)?;
83        std::fs::rename(&tmp, path)
84    }
85
86    /// `None` when absent or unreadable — an agent that never reloaded.
87    pub fn load(path: &std::path::Path) -> Option<Self> {
88        serde_json::from_slice(&std::fs::read(path).ok()?).ok()
89    }
90}
91
92impl AgentProfile {
93    /// Classify what changed between `old` and `new`.
94    pub fn reload_diff(old: &AgentProfile, new: &AgentProfile) -> ReloadDiff {
95        let (Ok(a), Ok(b)) = (serde_json::to_value(old), serde_json::to_value(new)) else {
96            // Unserializable is not a state the type can reach; if it ever
97            // does, refusing to hot-apply anything is the safe answer.
98            return ReloadDiff {
99                hot: vec![],
100                cold: vec!["<profile>".into()],
101            };
102        };
103        let mut diff = ReloadDiff::default();
104        walk(&a, &b, "", &mut diff);
105        diff.hot.sort();
106        diff.cold.sort();
107        diff
108    }
109}
110
111impl AgentProfile {
112    /// `self` (the profile the process booted with) with only the [`HOT_PATHS`]
113    /// fields taken from `newer`.
114    ///
115    /// How the runtime applies a reload: it never swaps in `newer` wholesale,
116    /// so a changed locked field cannot reach a reader by any route — the
117    /// running process keeps the boot value until a restart.
118    pub fn with_hot_fields_from(&self, newer: &AgentProfile) -> AgentProfile {
119        let mut out = self.clone();
120        out.limits = newer.limits.clone();
121        out.disabled_skills = newer.disabled_skills.clone();
122        out.addons = newer.addons.clone();
123        out.hitl.autonomy = newer.hitl.autonomy;
124        out.lifecycle.schedule = newer.lifecycle.schedule.clone();
125        out.lifecycle.idle_triggers = newer.lifecycle.idle_triggers.clone();
126        out
127    }
128}
129
130fn walk(a: &Value, b: &Value, prefix: &str, diff: &mut ReloadDiff) {
131    let null = Value::Null;
132    let (Some(ma), Some(mb)) = (a.as_object(), b.as_object()) else {
133        if a != b {
134            diff.cold.push(prefix.to_string());
135        }
136        return;
137    };
138    let mut keys: Vec<&String> = ma.keys().chain(mb.keys()).collect();
139    keys.sort();
140    keys.dedup();
141    for k in keys {
142        let path = if prefix.is_empty() {
143            k.clone()
144        } else {
145            format!("{prefix}.{k}")
146        };
147        let (va, vb) = (ma.get(k).unwrap_or(&null), mb.get(k).unwrap_or(&null));
148        if va == vb || IGNORED_PATHS.contains(&path.as_str()) {
149            continue;
150        }
151        if HOT_PATHS.contains(&path.as_str()) {
152            diff.hot.push(path.clone());
153            if path == "addons" {
154                diff.cold.push("addons (mcp servers)".into());
155            }
156        } else if prefix.is_empty() && NESTED_PARENTS.contains(&k.as_str()) {
157            walk(va, vb, &path, diff);
158        } else {
159            diff.cold.push(path);
160        }
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::agent::ScheduleEntry;
168
169    fn base() -> AgentProfile {
170        AgentProfile::default_for_tests()
171    }
172
173    #[test]
174    fn identical_profiles_have_no_diff() {
175        assert!(AgentProfile::reload_diff(&base(), &base()).is_empty());
176    }
177
178    #[test]
179    fn limits_and_autonomy_are_hot() {
180        let mut new = base();
181        new.limits = Some(crate::limits::Limits {
182            deadline: Some("5m".into()),
183            ..Default::default()
184        });
185        new.hitl.autonomy = Some(crate::hitl::Autonomy::Continue);
186        let d = AgentProfile::reload_diff(&base(), &new);
187        assert_eq!(d.hot, vec!["hitl.autonomy", "limits"]);
188        assert!(d.hot_only(), "{d:?}");
189    }
190
191    #[test]
192    fn schedule_is_hot_but_restart_policy_is_cold() {
193        let mut new = base();
194        new.lifecycle.schedule.push(ScheduleEntry {
195            cron: "0 9 * * *".into(),
196            message: "hi".into(),
197            sends_to: None,
198            not_after: None,
199        });
200        new.lifecycle.max_restarts += 1;
201        let d = AgentProfile::reload_diff(&base(), &new);
202        assert_eq!(d.hot, vec!["lifecycle.schedule"]);
203        assert_eq!(d.cold, vec!["lifecycle.max_restarts"]);
204    }
205
206    #[test]
207    fn locked_fields_are_cold() {
208        let mut new = base();
209        new.name = "other".into();
210        new.entitlements
211            .network
212            .outbound
213            .allow_hosts
214            .push("example.com".into());
215        new.disabled_mcp.push("x".into());
216        let d = AgentProfile::reload_diff(&base(), &new);
217        assert!(d.hot.is_empty(), "{d:?}");
218        assert!(d.cold.iter().any(|p| p == "name"), "{d:?}");
219        assert!(
220            d.cold.iter().any(|p| p.starts_with("entitlements")),
221            "{d:?}"
222        );
223        assert!(d.cold.iter().any(|p| p == "disabled_mcp"), "{d:?}");
224    }
225
226    #[test]
227    fn updated_at_alone_is_not_a_change() {
228        let mut new = base();
229        new.updated_at = "2099-01-01T00:00:00Z".into();
230        assert!(AgentProfile::reload_diff(&base(), &new).is_empty());
231    }
232
233    #[test]
234    fn overlay_applies_every_hot_path_and_nothing_else() {
235        let mut new = base();
236        new.limits = Some(crate::limits::Limits {
237            stuck: Some("off".into()),
238            ..Default::default()
239        });
240        new.disabled_skills.push("foo".into());
241        new.hitl.autonomy = Some(crate::hitl::Autonomy::Continue);
242        new.lifecycle.idle_triggers.push(crate::agent::IdleTrigger {
243            after_secs: 60,
244            message: "m".into(),
245            sends_to: None,
246            cooldown_secs: 600,
247            respect_quiet_hours: false,
248        });
249        new.name = "renamed".into();
250        let applied = base().with_hot_fields_from(&new);
251        let rest = AgentProfile::reload_diff(&applied, &new);
252        // Everything hot made it across; the locked rename did not.
253        assert!(rest.hot.is_empty(), "{rest:?}");
254        assert_eq!(rest.cold, vec!["name"]);
255    }
256
257    #[test]
258    fn disabled_skills_is_hot() {
259        let mut new = base();
260        new.disabled_skills.push("foo".into());
261        let d = AgentProfile::reload_diff(&base(), &new);
262        assert_eq!(d.hot, vec!["disabled_skills"]);
263        assert!(d.cold.is_empty());
264    }
265}