Skip to main content

lean_ctx/core/
compact_directive.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Store for the agent-driven conversation-compaction directive (#1570 P1).
4//!
5//! The MCP server cannot rewrite the client's conversation — only the proxy
6//! sees the messages. `ctx_session(action="compact")` therefore records a
7//! *directive*; the proxy applies it to the requests of the app session that
8//! armed it (cache-safe, live-suffix only). Restore is lossless by
9//! construction: the client resends the full original history each turn, so
10//! clearing the directive is enough.
11//!
12//! One proxy carries many app sessions (the launching app's
13//! `x-leanctx-session-id` header, `LEAN_CTX_SESSION_ID` for the agent's MCP
14//! server). Every directive belongs to exactly one of them: it is stored
15//! under that session, applied only to requests naming it, and a request
16//! that names no valid session gets none (fail closed). The proxy also notes
17//! each session it carries, so `ctx_session compact` can say honestly
18//! whether a proxy would apply the directive at all.
19
20use std::path::PathBuf;
21use std::time::{SystemTime, UNIX_EPOCH};
22
23use serde::{Deserialize, Serialize};
24
25/// A directive older than this is ignored and cleaned up.
26const TTL_SECS: u64 = 1800;
27
28/// Anti-obsession guard (#1570 P6): a fresh compact may only be created this
29/// long after the session's previous one.
30const MIN_INTERVAL_SECS: u64 = 300;
31
32/// A session counts as carried by a proxy when one of its requests reached a
33/// proxy within this window: an armed directive would apply to its next turn.
34const CARRIED_WINDOW_SECS: u64 = TTL_SECS;
35
36/// The proxy refreshes a session's carried note at most this often.
37const CARRIED_REFRESH_SECS: u64 = 60;
38
39const STORE_DIR: &str = "compact_directives";
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct CompactDirective {
43    pub created_unix: u64,
44    /// How many trailing plain-user turns stay verbatim.
45    pub keep_recent_turns: usize,
46    /// The agent-authored summary that replaces the compacted span.
47    pub summary: String,
48    /// Fingerprint of the first message AFTER the cut, pinned on first apply
49    /// so the replaced prefix stays byte-identical while the conversation
50    /// grows (provider prompt caches keep hitting).
51    pub boundary_fingerprint: Option<String>,
52    /// CCR handle of the verbatim replaced span (defense in depth — restore
53    /// itself only needs the directive cleared).
54    pub original_handle: Option<String>,
55}
56
57#[cfg(not(any(test, feature = "test-support")))]
58fn now_unix() -> u64 {
59    system_now()
60}
61
62#[cfg(any(test, feature = "test-support"))]
63fn now_unix() -> u64 {
64    system_now().saturating_add(TEST_CLOCK_AHEAD.load(std::sync::atomic::Ordering::SeqCst))
65}
66
67fn system_now() -> u64 {
68    SystemTime::now()
69        .duration_since(UNIX_EPOCH)
70        .map(|d| d.as_secs())
71        .unwrap_or(0)
72}
73
74/// Seconds the directive clock runs ahead of the system clock in tests.
75#[cfg(any(test, feature = "test-support"))]
76static TEST_CLOCK_AHEAD: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
77
78/// Moves the directive clock ahead until the guard drops. The offset is
79/// process-wide: hold `core::data_dir::isolated_data_dir()` for as long as a
80/// guard lives.
81#[cfg(any(test, feature = "test-support"))]
82pub struct TestClock;
83
84#[cfg(any(test, feature = "test-support"))]
85impl TestClock {
86    /// One second past the minimum re-arm interval of every directive armed
87    /// so far, still inside their TTL.
88    #[must_use]
89    pub fn past_min_interval() -> Self {
90        TEST_CLOCK_AHEAD.fetch_add(MIN_INTERVAL_SECS + 1, std::sync::atomic::Ordering::SeqCst);
91        Self
92    }
93
94    /// One second past the TTL of every directive armed so far.
95    #[must_use]
96    pub fn past_ttl() -> Self {
97        TEST_CLOCK_AHEAD.fetch_add(TTL_SECS + 1, std::sync::atomic::Ordering::SeqCst);
98        Self
99    }
100}
101
102#[cfg(any(test, feature = "test-support"))]
103impl Drop for TestClock {
104    fn drop(&mut self) {
105        TEST_CLOCK_AHEAD.store(0, std::sync::atomic::Ordering::SeqCst);
106    }
107}
108
109/// The store file of `session` with `suffix`, or `None` when `session` is
110/// no valid app session id or there is no data dir. Ids are hashed into the
111/// file name: they may carry characters some file systems refuse.
112fn session_path(session: &str, suffix: &str) -> Option<PathBuf> {
113    let session = crate::core::activity::session_id(session)?;
114    let dir = crate::core::data_dir::lean_ctx_data_dir()
115        .ok()?
116        .join(STORE_DIR);
117    let key = blake3::hash(session.as_bytes()).to_hex();
118    Some(dir.join(format!("{}.{suffix}", &key[..32])))
119}
120
121fn directive_path(session: &str) -> Option<PathBuf> {
122    session_path(session, "json")
123}
124
125fn carried_path(session: &str) -> Option<PathBuf> {
126    session_path(session, "carried")
127}
128
129/// Write `contents` to `path` through a sibling temp file and a rename, so a
130/// reader in another process never sees half a file. Every write gets its
131/// own temp name (file name, pid, process-wide counter): a session's
132/// directive and carried note, and concurrent requests of one session, are
133/// written from the same proxy process at the same time.
134///
135/// Once the temp file exists, every failure removes it (a full disk would
136/// otherwise leave one orphan per proxied request). The replace goes through
137/// `atomic_fs::replace_file`, which retries a target Windows Defender or the
138/// indexer briefly holds open (#956).
139fn write_atomic(path: &std::path::Path, contents: &str) -> Result<(), String> {
140    use std::io::Write as _;
141    static WRITES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
142    let dir = path.parent().ok_or_else(|| "no store dir".to_string())?;
143    let name = path
144        .file_name()
145        .and_then(|n| n.to_str())
146        .ok_or_else(|| "no store file name".to_string())?;
147    std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
148    let n = WRITES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
149    let tmp = dir.join(format!("{name}.tmp{}.{n}", std::process::id()));
150    // `create_new`: never truncate or adopt a file this write did not create,
151    // so the cleanup below only ever removes our own temp file.
152    let mut file = std::fs::OpenOptions::new()
153        .write(true)
154        .create_new(true)
155        .open(&tmp)
156        .map_err(|e| e.to_string())?;
157    let written = file.write_all(contents.as_bytes()).and_then(|()| {
158        drop(file);
159        crate::core::atomic_fs::replace_file(&tmp, path)
160    });
161    written.map_err(|e| {
162        let _ = std::fs::remove_file(&tmp);
163        e.to_string()
164    })
165}
166
167/// Create a new directive for `session`. Fails when the session has no
168/// valid id, or when its previous one was created less than
169/// `MIN_INTERVAL_SECS` ago (rate limit — nothing is ever blocked, the agent
170/// simply keeps its current, already-compacted context).
171pub fn create(session: &str, keep_recent_turns: usize, summary: String) -> Result<(), String> {
172    if let Some(existing) = load_active(session)
173        && now_unix().saturating_sub(existing.created_unix) < MIN_INTERVAL_SECS
174    {
175        return Err(format!(
176            "a compaction from {}s ago is still active — one compact per {}s \
177             (anti-obsession guard); use ctx_session action=\"restore\" first if needed",
178            now_unix().saturating_sub(existing.created_unix),
179            MIN_INTERVAL_SECS
180        ));
181    }
182    let directive = CompactDirective {
183        created_unix: now_unix(),
184        keep_recent_turns,
185        summary,
186        boundary_fingerprint: None,
187        original_handle: None,
188    };
189    save(session, &directive)
190}
191
192pub fn save(session: &str, directive: &CompactDirective) -> Result<(), String> {
193    let mut admitted = directive.clone();
194    admitted.summary =
195        crate::core::session::admission::admit_storable_tool_text(&directive.summary)?;
196    write_admitted(session, &admitted)
197}
198
199fn write_admitted(session: &str, directive: &CompactDirective) -> Result<(), String> {
200    let path =
201        directive_path(session).ok_or_else(|| "no app session id or data dir".to_string())?;
202    let json = serde_json::to_string(directive).map_err(|e| e.to_string())?;
203    write_atomic(&path, &json)
204}
205
206/// The directive of `session` when it exists and is younger than `TTL_SECS`.
207pub fn load_active(session: &str) -> Option<CompactDirective> {
208    let path = directive_path(session)?;
209    let raw = std::fs::read_to_string(&path).ok()?;
210    let directive: CompactDirective = serde_json::from_str(&raw).ok()?;
211    if now_unix().saturating_sub(directive.created_unix) > TTL_SECS {
212        let _ = std::fs::remove_file(&path);
213        return None;
214    }
215    let original_summary = directive.summary.clone();
216    let Ok(admitted_summary) =
217        crate::core::session::admission::admit_storable_tool_text(&original_summary)
218    else {
219        let _ = std::fs::remove_file(path);
220        return None;
221    };
222    let mut admitted = directive;
223    admitted.summary = admitted_summary;
224    if admitted.summary != original_summary {
225        write_admitted(session, &admitted).ok()?;
226    }
227    Some(admitted)
228}
229
230/// Drop the directive of `session` — its next request carries the full
231/// original history again, so this alone is a complete, lossless restore.
232pub fn clear(session: &str) -> bool {
233    directive_path(session).is_some_and(|path| std::fs::remove_file(path).is_ok())
234}
235
236/// The proxy carried a request of `session` now. Best effort and throttled:
237/// a failed note only makes a later `compact` reply say "no proxy".
238pub fn note_carried(session: &str) {
239    let Some(path) = carried_path(session) else {
240        return;
241    };
242    let now = now_unix();
243    let fresh =
244        read_carried(&path).is_some_and(|seen| seen <= now && now - seen < CARRIED_REFRESH_SECS);
245    if !fresh {
246        let _ = write_atomic(&path, &now.to_string());
247    }
248}
249
250/// Whether a proxy carried a request of `session` within the carried window.
251pub fn is_carried(session: &str) -> bool {
252    carried_path(session)
253        .and_then(|path| read_carried(&path))
254        .is_some_and(|seen| now_unix().saturating_sub(seen) <= CARRIED_WINDOW_SECS)
255}
256
257fn read_carried(path: &std::path::Path) -> Option<u64> {
258    std::fs::read_to_string(path).ok()?.trim().parse().ok()
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn directive_roundtrip_rate_limit_and_clear() {
267        let _dir = crate::core::data_dir::isolated_data_dir();
268        let a = "session-a";
269        assert!(load_active(a).is_none());
270
271        create(a, 6, "summary one".into()).expect("first create succeeds");
272        let d = load_active(a).expect("directive active");
273        assert_eq!(d.keep_recent_turns, 6);
274        assert!(d.boundary_fingerprint.is_none());
275
276        // Rate limit: a second compact right away is refused with guidance.
277        let err = create(a, 4, "summary two".into()).expect_err("rate-limited");
278        assert!(err.contains("anti-obsession"), "{err}");
279
280        assert!(clear(a));
281        assert!(load_active(a).is_none());
282        // After clear, creating again is allowed immediately.
283        create(a, 4, "summary three".into()).expect("create after clear");
284        clear(a);
285    }
286
287    #[test]
288    fn directives_belong_to_one_session_and_need_a_valid_id() {
289        let _dir = crate::core::data_dir::isolated_data_dir();
290        create("session-a", 4, "for a".into()).expect("a arms");
291        assert!(load_active("session-b").is_none(), "b sees nothing");
292        create("session-b", 4, "for b".into()).expect("b's own guard");
293        assert!(!clear("session-c"));
294        assert_eq!(load_active("session-a").unwrap().summary, "for a");
295        for bad in ["", "  ", "bad id", "../x"] {
296            assert!(create(bad, 4, "x".into()).is_err(), "{bad:?}");
297            assert!(load_active(bad).is_none());
298            assert!(!clear(bad));
299            note_carried(bad);
300            assert!(!is_carried(bad));
301        }
302    }
303
304    #[test]
305    fn expired_directive_is_ignored_and_removed() {
306        let _dir = crate::core::data_dir::isolated_data_dir();
307        let stale = CompactDirective {
308            created_unix: now_unix() - TTL_SECS - 10,
309            keep_recent_turns: 6,
310            summary: "old".into(),
311            boundary_fingerprint: None,
312            original_handle: None,
313        };
314        save("session-a", &stale).unwrap();
315        assert!(
316            load_active("session-a").is_none(),
317            "expired directive must not apply"
318        );
319        assert!(load_active("session-a").is_none());
320    }
321
322    /// A session's directive and carried note are written concurrently from
323    /// one proxy process; neither write may land in the other's file.
324    #[test]
325    fn concurrent_writes_of_one_session_keep_both_files_whole() {
326        let _dir = crate::core::data_dir::isolated_data_dir();
327        let session = "session-concurrent";
328        let directive = CompactDirective {
329            created_unix: now_unix(),
330            keep_recent_turns: 4,
331            summary: "concurrent summary".into(),
332            boundary_fingerprint: Some("0123456789abcdef".into()),
333            original_handle: None,
334        };
335        let json = serde_json::to_string(&directive).unwrap();
336        let (directive_file, carried_file) = (
337            directive_path(session).unwrap(),
338            carried_path(session).unwrap(),
339        );
340        std::thread::scope(|scope| {
341            for _ in 0..8 {
342                scope.spawn(|| {
343                    for _ in 0..50 {
344                        write_atomic(&directive_file, &json).expect("directive write");
345                        write_atomic(&carried_file, &now_unix().to_string())
346                            .expect("carried write");
347                        save(session, &directive).expect("save");
348                        note_carried(session);
349                    }
350                });
351            }
352        });
353        let stored = load_active(session).expect("the directive parses");
354        assert_eq!(stored.summary, "concurrent summary");
355        assert!(is_carried(session), "the carried note parses");
356        let leftovers: Vec<_> = std::fs::read_dir(directive_file.parent().unwrap())
357            .unwrap()
358            .filter_map(Result::ok)
359            .map(|e| e.file_name())
360            .filter(|n| n.to_string_lossy().contains(".tmp"))
361            .collect();
362        assert!(leftovers.is_empty(), "no temp files left: {leftovers:?}");
363    }
364
365    /// A write that fails after its temp file exists removes the temp file
366    /// again: a failing store (full disk, a target that cannot be replaced)
367    /// is retried on every proxied request and must not leak one file each.
368    #[test]
369    fn failed_writes_leave_no_temp_files() {
370        let _dir = crate::core::data_dir::isolated_data_dir();
371        let carried = carried_path("session-fail").unwrap();
372        // A non-empty directory at the target: the replace fails every time.
373        std::fs::create_dir_all(carried.join("blocker")).unwrap();
374        for _ in 0..3 {
375            assert!(write_atomic(&carried, "1").is_err(), "the write must fail");
376            note_carried("session-fail");
377        }
378        let leftovers: Vec<_> = std::fs::read_dir(carried.parent().unwrap())
379            .unwrap()
380            .filter_map(Result::ok)
381            .map(|e| e.file_name())
382            .filter(|n| n.to_string_lossy().contains(".tmp"))
383            .collect();
384        assert!(leftovers.is_empty(), "no temp files left: {leftovers:?}");
385        assert!(!is_carried("session-fail"));
386    }
387
388    #[test]
389    fn carried_note_lasts_the_window() {
390        let _dir = crate::core::data_dir::isolated_data_dir();
391        assert!(!is_carried("session-a"));
392        note_carried("session-a");
393        assert!(is_carried("session-a"));
394        assert!(!is_carried("session-b"));
395        let _later = TestClock::past_ttl();
396        assert!(!is_carried("session-a"), "an old note no longer counts");
397    }
398}