Skip to main content

agentmux_srv/backend/
native_memory_retention.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Retention/GC for `db_agent_native_memory_versions` — closes §7.1 of
5//! docs/specs/SPEC_MEMORY_VERSION_CONTROL_AND_ARMORY_AUDIT_2026_08_19.md,
6//! left open in v1 ("no pruning is proposed... flag if unbounded growth is
7//! a concern"). Operator-confirmed policy (2026-08-22): hybrid age +
8//! min-count floor — a version is pruned only once it is BOTH older than
9//! [`MAX_AGE_MS`] AND ranked beyond the [`MIN_KEEP`] most-recent versions
10//! for its `(agent_id, filename)`. The floor exists so an agent that
11//! rarely touches a given memory file never loses its entire history just
12//! because every version happens to be old; the age bound exists so a
13//! hyperactive agent's frequently-rewritten file doesn't grow unbounded
14//! just because every version happens to be recent. See
15//! `agent_native_memory_versions.rs::agent_native_memory_version_prune`
16//! for the actual query.
17//!
18//! Runs as its own periodic background sweep, started once at server
19//! startup alongside `native_memory_drift`'s detection sweeps (a sibling
20//! concern, not the same one — drift *detects* out-of-band writes,
21//! retention *prunes* old versions regardless of source). Deliberately a
22//! much slower cadence than drift detection's 30s: pruning is housekeeping,
23//! not latency-sensitive — nothing is waiting on a stale version's removal
24//! the way a reviewer is waiting to see a drifted write show up.
25
26use std::sync::Arc;
27use std::time::Duration;
28
29use crate::backend::storage::store::Store;
30
31/// Pruning is housekeeping, not latency-sensitive — once a day is generous
32/// enough that growth stays bounded without adding meaningful DB load.
33const SWEEP_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
34
35/// Keep at least this many most-recent versions per `(agent_id, filename)`,
36/// regardless of age.
37pub(crate) const MIN_KEEP: u32 = 50;
38
39/// Beyond `MIN_KEEP`, prune versions older than this.
40pub(crate) const MAX_AGE_MS: i64 = 90 * 24 * 60 * 60 * 1000;
41
42/// One pass: prune every `(agent_id, filename)` pair that has any recorded
43/// version history. Returns the number of version rows deleted (for
44/// logging; tests assert on it directly). A failure listing targets or
45/// pruning one file is logged and does not stop the sweep from covering
46/// everything else — matches `native_memory_drift`'s own per-item error
47/// handling.
48pub(crate) fn prune_once(id_store: &Store) -> usize {
49    let targets = match id_store.agent_native_memory_version_list_distinct_files() {
50        Ok(t) => t,
51        Err(e) => {
52            tracing::warn!(error = %e, "native_memory_retention: failed to list files needing pruning");
53            return 0;
54        }
55    };
56
57    let mut pruned = 0;
58    for (agent_id, filename) in targets {
59        match id_store.agent_native_memory_version_prune(&agent_id, &filename, MIN_KEEP, MAX_AGE_MS) {
60            Ok(0) => {}
61            Ok(n) => {
62                pruned += n;
63                tracing::info!(agent_id, filename, pruned = n, "native_memory_retention: pruned old versions");
64            }
65            Err(e) => tracing::warn!(agent_id, filename, error = %e, "native_memory_retention: prune failed"),
66        }
67    }
68    pruned
69}
70
71/// Start the periodic sweep. Call once at server startup with the store
72/// version history is recorded in. Returns immediately — runs as a
73/// spawned background task for the process lifetime (no shutdown handle;
74/// srv itself owns the process lifetime, same as `native_memory_drift::spawn`).
75pub fn spawn(id_store: Arc<Store>) {
76    tokio::spawn(async move {
77        let mut tick = tokio::time::interval(SWEEP_INTERVAL);
78        loop {
79            tick.tick().await;
80            prune_once(&id_store);
81        }
82    });
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use rusqlite::params;
89
90    fn shared_store() -> Store {
91        Store::open_shared(std::path::Path::new(":memory:")).unwrap()
92    }
93
94    const DAY_MS: i64 = 24 * 60 * 60 * 1000;
95
96    fn backdate(store: &Store, version_id: &str, days_ago: i64) {
97        let now_ms = agentmux_common::time::now_ms();
98        let conn = store.conn().lock().unwrap();
99        conn.execute(
100            "UPDATE db_agent_native_memory_versions SET created_at = ?1 WHERE id = ?2",
101            params![now_ms - days_ago * DAY_MS, version_id],
102        )
103        .unwrap();
104    }
105
106    #[test]
107    fn prune_once_covers_every_file_with_recorded_history() {
108        let store = shared_store();
109        for (agent_id, filename) in [("agent-1", "a.md"), ("agent-1", "b.md"), ("agent-2", "a.md")] {
110            for i in 0..(MIN_KEEP + 3) {
111                let v = store
112                    .agent_native_memory_version_insert(agent_id, filename, &format!("v{i}"), "human", "{}", "")
113                    .unwrap();
114                backdate(&store, &v.id, 200); // past MAX_AGE_MS
115            }
116        }
117
118        let pruned = prune_once(&store);
119        assert_eq!(pruned, 3 * 3, "3 excess versions per file, across 3 distinct files");
120
121        for (agent_id, filename) in [("agent-1", "a.md"), ("agent-1", "b.md"), ("agent-2", "a.md")] {
122            assert_eq!(
123                store.agent_native_memory_version_list(agent_id, filename).unwrap().len(),
124                MIN_KEEP as usize
125            );
126        }
127    }
128
129    #[test]
130    fn prune_once_is_a_no_op_when_nothing_qualifies() {
131        let store = shared_store();
132        store
133            .agent_native_memory_version_insert("agent-1", "MEMORY.md", "v1", "human", "{}", "")
134            .unwrap();
135        assert_eq!(prune_once(&store), 0);
136        assert_eq!(store.agent_native_memory_version_list("agent-1", "MEMORY.md").unwrap().len(), 1);
137    }
138
139    #[test]
140    fn prune_once_returns_zero_when_no_version_history_exists_at_all() {
141        let store = shared_store();
142        assert_eq!(prune_once(&store), 0);
143    }
144}