Skip to main content

lean_ctx/core/
binary_identity.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The binary this process started from, so a long-running MCP server can say
3//! when an install replaced it on disk. An MCP server keeps running the engine
4//! it started with: on 25.10. every agent session still ran a two-day-old
5//! engine after three installs, and fixes reached nobody until a reconnect.
6
7use std::path::PathBuf;
8use std::sync::OnceLock;
9use std::time::SystemTime;
10
11/// What identifies the executable file: a replaced file has another inode
12/// (atomic install), and in any case another size or modification time.
13#[derive(Debug, Clone, PartialEq, Eq)]
14struct Identity {
15    path: PathBuf,
16    len: u64,
17    modified: Option<SystemTime>,
18    inode: u64,
19}
20
21static AT_START: OnceLock<Option<Identity>> = OnceLock::new();
22
23fn current() -> Option<Identity> {
24    let path = std::env::current_exe().ok()?;
25    let meta = std::fs::metadata(&path).ok()?;
26    #[cfg(unix)]
27    let inode = std::os::unix::fs::MetadataExt::ino(&meta);
28    #[cfg(not(unix))]
29    let inode = 0;
30    Some(Identity {
31        path,
32        len: meta.len(),
33        modified: meta.modified().ok(),
34        inode,
35    })
36}
37
38/// Record the executable as it is now; call once, early in `main`.
39pub fn record_start() {
40    let _ = AT_START.get_or_init(current);
41}
42
43/// The note for an agent when the binary on disk is no longer the one this
44/// process started from; `None` while they match (or nothing was recorded).
45#[must_use]
46pub fn replaced_note() -> Option<String> {
47    let start = AT_START.get()?.as_ref()?;
48    note_for(start, &current()?)
49}
50
51fn note_for(start: &Identity, now: &Identity) -> Option<String> {
52    (start != now).then(|| {
53        "Note: lean-ctx was updated on disk after this MCP server started; it still runs the \
54         old engine. Reconnect the MCP server (Claude Code: /mcp, then reconnect lean-ctx) to \
55         use the new one. A reconnect gives this session a new bus id: register your role \
56         again and repost your open claims under the new id.\n"
57            .to_string()
58    })
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64
65    fn identity(inode: u64, len: u64) -> Identity {
66        Identity {
67            path: PathBuf::from("/usr/local/bin/lean-ctx"),
68            len,
69            modified: Some(SystemTime::UNIX_EPOCH),
70            inode,
71        }
72    }
73
74    #[test]
75    fn a_replaced_binary_is_named_and_an_unchanged_one_is_not() {
76        let start = identity(7, 100);
77        assert_eq!(note_for(&start, &identity(7, 100)), None);
78        let replaced = note_for(&start, &identity(8, 100)).expect("new inode");
79        assert!(replaced.contains("Reconnect the MCP server"), "{replaced}");
80        assert!(
81            note_for(&start, &identity(7, 101)).is_some(),
82            "rewritten in place"
83        );
84    }
85
86    #[test]
87    fn the_running_test_binary_matches_itself() {
88        record_start();
89        assert_eq!(replaced_note(), None);
90    }
91}