Skip to main content

a3s_code_core/memory/
semantic_refresh.rs

1#[path = "semantic_refresh/metrics.rs"]
2mod metrics;
3
4pub use metrics::{
5    SemanticRefreshMetrics, SemanticRefreshRunMetrics, SemanticRefreshRunOutcome,
6    SEMANTIC_REFRESH_RECENT_RUN_LIMIT,
7};
8
9use super::maintenance::{
10    validate_interval, MemoryMaintenanceContext, MemoryMaintenanceError, MemoryMaintenanceJob,
11    MemoryMaintenanceOutcome, ScheduledMemoryMaintenance,
12};
13use super::AgentMemory;
14use crate::durable_memory::{
15    DurableMemorySemanticRefreshCheckpoint, DurableMemorySemanticRefreshReceipt,
16    DurableMemorySemanticRefreshRun, SemanticRefreshEmbeddingCache,
17};
18use a3s_memory::vector::VectorMutationConsistency;
19use async_trait::async_trait;
20use metrics::SemanticRefreshRunObservation;
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::{Arc, RwLock};
23use std::time::Duration;
24use tokio_util::sync::CancellationToken;
25
26/// Reserved health name of Code's verified semantic refresh worker.
27pub const SEMANTIC_REFRESH_JOB_NAME: &str = "v2_semantic_refresh";
28
29/// Explicit periodic schedule for verified semantic memory refresh.
30///
31/// The schedule always requires atomic global index-revision CAS. This prevents
32/// independently constructed session runtimes from silently falling back to a
33/// process-local ordering guarantee. Construction is inert; a worker starts
34/// only after the schedule is installed in [`super::MemoryMaintenanceOptions`]
35/// and an asynchronous session is built. Clones form one ownership family, so
36/// only one active maintenance runtime can publish its shared receipt and
37/// metrics epoch at once.
38#[derive(Clone)]
39#[must_use = "a semantic refresh schedule does nothing until installed in maintenance options"]
40pub struct ScheduledSemanticRefresh {
41    interval: Duration,
42    state: Arc<RwLock<ScheduledSemanticRefreshState>>,
43    claimed: Arc<AtomicBool>,
44}
45
46#[derive(Default)]
47struct ScheduledSemanticRefreshState {
48    last_receipt: Option<DurableMemorySemanticRefreshReceipt>,
49    recovery_receipt: Option<DurableMemorySemanticRefreshReceipt>,
50    embedding_cache: Option<Arc<SemanticRefreshEmbeddingCache>>,
51    metrics: SemanticRefreshMetrics,
52}
53
54impl ScheduledSemanticRefresh {
55    pub fn try_new(interval: Duration) -> Result<Self, MemoryMaintenanceError> {
56        Self::try_new_inner(interval, None)
57    }
58
59    /// Construct a schedule that verifies and adopts persisted refresh evidence.
60    ///
61    /// The checkpoint never authorizes the repository-token fast path. Its
62    /// first run reads a complete bounded Active snapshot and checks the current
63    /// index before it can become this ownership epoch's successful receipt.
64    pub fn try_new_with_checkpoint(
65        interval: Duration,
66        checkpoint: DurableMemorySemanticRefreshCheckpoint,
67    ) -> Result<Self, MemoryMaintenanceError> {
68        checkpoint.verify().map_err(|error| {
69            invalid(
70                "semanticRefresh.checkpoint",
71                format!("failed validation: {error}"),
72            )
73        })?;
74        Self::try_new_inner(interval, Some(checkpoint.into_recovery_receipt()))
75    }
76
77    fn try_new_inner(
78        interval: Duration,
79        recovery_receipt: Option<DurableMemorySemanticRefreshReceipt>,
80    ) -> Result<Self, MemoryMaintenanceError> {
81        validate_interval(interval)?;
82        Ok(Self {
83            interval,
84            state: Arc::new(RwLock::new(ScheduledSemanticRefreshState {
85                recovery_receipt,
86                ..ScheduledSemanticRefreshState::default()
87            })),
88            claimed: Arc::new(AtomicBool::new(false)),
89        })
90    }
91
92    pub fn interval(&self) -> Duration {
93        self.interval
94    }
95
96    pub fn required_consistency(&self) -> VectorMutationConsistency {
97        VectorMutationConsistency::IndexRevisionCas
98    }
99
100    /// Return the most recent successful, secret-free refresh receipt.
101    ///
102    /// Clones share this observation state for one active ownership epoch. A
103    /// failed later run leaves the last successful receipt intact while generic
104    /// maintenance health records the failure. A replacement owner starts a new
105    /// epoch and clears the process-local receipt before its first run, so an
106    /// optional source change token is never reused across repository owners.
107    pub fn last_receipt(&self) -> Option<DurableMemorySemanticRefreshReceipt> {
108        read_unpoisoned(&self.state).last_receipt.clone()
109    }
110
111    /// Return bounded, non-sensitive evidence for the current ownership epoch.
112    ///
113    /// A never-owned schedule reports epoch zero. Clean close retains the last
114    /// epoch for inspection; a successful replacement claim increments the
115    /// epoch and clears all prior counters and recent runs.
116    pub fn metrics(&self) -> SemanticRefreshMetrics {
117        read_unpoisoned(&self.state).metrics.clone()
118    }
119
120    pub(super) fn validate_for(&self, memory: &AgentMemory) -> Result<(), MemoryMaintenanceError> {
121        let durable = memory.durable_memory().ok_or_else(|| {
122            invalid(
123                "semanticRefresh",
124                "requires an exact durable-memory binding",
125            )
126        })?;
127        let semantic = durable.semantic_recall().ok_or_else(|| {
128            invalid(
129                "semanticRefresh",
130                "requires an attached semantic recall generation",
131            )
132        })?;
133        let actual = semantic.mutation_consistency();
134        if actual != self.required_consistency() {
135            return Err(invalid(
136                "semanticRefresh.mutationConsistency",
137                format!(
138                    "requires {:?}, but the backend provides {actual:?}",
139                    self.required_consistency()
140                ),
141            ));
142        }
143        Ok(())
144    }
145
146    pub(super) fn try_claim(
147        &self,
148    ) -> Result<ScheduledSemanticRefreshClaim, MemoryMaintenanceError> {
149        let claim = self
150            .claimed
151            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
152            .map(|_| ScheduledSemanticRefreshClaim {
153                _lease: Arc::new(ScheduledSemanticRefreshClaimLease {
154                    claimed: Arc::clone(&self.claimed),
155                    state: Arc::clone(&self.state),
156                }),
157            })
158            .map_err(|_| MemoryMaintenanceError::SemanticRefreshAlreadyOwned)?;
159        let mut state = write_unpoisoned(&self.state);
160        let ownership_epoch = state.metrics.ownership_epoch().saturating_add(1);
161        let recovery_receipt = state.recovery_receipt.take();
162        *state = ScheduledSemanticRefreshState {
163            recovery_receipt,
164            metrics: SemanticRefreshMetrics::for_epoch(ownership_epoch),
165            ..ScheduledSemanticRefreshState::default()
166        };
167        Ok(claim)
168    }
169
170    pub(super) fn as_maintenance(
171        &self,
172    ) -> Result<ScheduledMemoryMaintenance, MemoryMaintenanceError> {
173        ScheduledMemoryMaintenance::try_new(
174            SEMANTIC_REFRESH_JOB_NAME,
175            self.interval,
176            Arc::new(SemanticRefreshJob {
177                state: Arc::clone(&self.state),
178            }),
179        )
180    }
181}
182
183#[derive(Clone)]
184pub(super) struct ScheduledSemanticRefreshClaim {
185    _lease: Arc<ScheduledSemanticRefreshClaimLease>,
186}
187
188struct ScheduledSemanticRefreshClaimLease {
189    claimed: Arc<AtomicBool>,
190    state: Arc<RwLock<ScheduledSemanticRefreshState>>,
191}
192
193impl Drop for ScheduledSemanticRefreshClaimLease {
194    fn drop(&mut self) {
195        // The host-held schedule keeps the receipt observable after close, but
196        // vectors are useful only while this ownership epoch can run again.
197        // Clear them before making the next claim visible.
198        write_unpoisoned(&self.state).embedding_cache = None;
199        self.claimed.store(false, Ordering::Release);
200    }
201}
202
203impl std::fmt::Debug for ScheduledSemanticRefresh {
204    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205        let state = read_unpoisoned(&self.state);
206        formatter
207            .debug_struct("ScheduledSemanticRefresh")
208            .field("interval", &self.interval)
209            .field("required_consistency", &self.required_consistency())
210            .field("has_receipt", &state.last_receipt.is_some())
211            .field("has_recovery_checkpoint", &state.recovery_receipt.is_some())
212            .field("ownership_epoch", &state.metrics.ownership_epoch())
213            .field("attempted_runs", &state.metrics.attempted_runs())
214            .finish()
215    }
216}
217
218struct SemanticRefreshJob {
219    state: Arc<RwLock<ScheduledSemanticRefreshState>>,
220}
221
222#[async_trait]
223impl MemoryMaintenanceJob for SemanticRefreshJob {
224    async fn run(
225        &self,
226        context: &MemoryMaintenanceContext,
227        cancellation: CancellationToken,
228    ) -> anyhow::Result<MemoryMaintenanceOutcome> {
229        let durable = context
230            .durable_memory()
231            .ok_or_else(|| anyhow::anyhow!("scheduled semantic refresh binding is unavailable"))?;
232        let (previous, previous_cache, previous_requires_index_continuity) = {
233            let state = read_unpoisoned(&self.state);
234            let (previous, requires_continuity) = match state.last_receipt.as_ref() {
235                Some(receipt) => (Some(receipt.clone()), false),
236                None => (
237                    state.recovery_receipt.clone(),
238                    state.recovery_receipt.is_some(),
239                ),
240            };
241            (previous, state.embedding_cache.clone(), requires_continuity)
242        };
243        let attempt = durable
244            .refresh_semantic_recall_scheduled(
245                previous.as_ref(),
246                previous_cache.as_deref(),
247                previous_requires_index_continuity,
248                cancellation,
249            )
250            .await;
251        let outcome = match &attempt.result {
252            Ok(DurableMemorySemanticRefreshRun::Published { .. }) => {
253                SemanticRefreshRunOutcome::Published
254            }
255            Ok(DurableMemorySemanticRefreshRun::Unchanged(_)) => {
256                SemanticRefreshRunOutcome::Unchanged
257            }
258            Err(_) => SemanticRefreshRunOutcome::Failed,
259        };
260        let observation = SemanticRefreshRunObservation {
261            outcome,
262            elapsed: attempt.elapsed,
263            source_change_token_requests: attempt.work.source_change_token_requests,
264            source_change_token_observations: attempt.work.source_change_token_observations,
265            source_snapshot_requests: attempt.work.source_snapshot_requests,
266            source_snapshot_node_reads: attempt.work.source_snapshot_node_reads,
267            source_snapshot_bytes: attempt.work.source_snapshot_bytes,
268            embedding_cache_hits: attempt.work.embedding_cache_hits,
269            embedding_inputs: attempt.work.embedding_inputs,
270            embedding_input_bytes: attempt.work.embedding_input_bytes,
271            provider_requests: attempt.work.provider_requests,
272            provider_inputs: attempt.work.provider_inputs,
273            provider_input_bytes: attempt.work.provider_input_bytes,
274            publication_attempts: attempt.work.publication_attempts,
275            publication_records: attempt.work.publication_records,
276        };
277        let mut state = write_unpoisoned(&self.state);
278        state.metrics.record(observation);
279        let affected_items = match attempt.result {
280            Ok(DurableMemorySemanticRefreshRun::Published {
281                receipt,
282                embedding_cache,
283            }) => {
284                let affected_items = receipt.active_node_count();
285                state.last_receipt = Some(receipt);
286                state.recovery_receipt = None;
287                state.embedding_cache = embedding_cache;
288                affected_items
289            }
290            Ok(DurableMemorySemanticRefreshRun::Unchanged(receipt)) => {
291                state.last_receipt = Some(receipt);
292                state.recovery_receipt = None;
293                0
294            }
295            Err(error) => {
296                return Err(anyhow::anyhow!(error.redacted_message()));
297            }
298        };
299        Ok(MemoryMaintenanceOutcome::new(affected_items))
300    }
301}
302
303fn invalid(field: &'static str, reason: impl Into<String>) -> MemoryMaintenanceError {
304    MemoryMaintenanceError::InvalidConfiguration {
305        field,
306        reason: reason.into(),
307    }
308}
309
310fn read_unpoisoned<T>(lock: &RwLock<T>) -> std::sync::RwLockReadGuard<'_, T> {
311    lock.read()
312        .unwrap_or_else(std::sync::PoisonError::into_inner)
313}
314
315fn write_unpoisoned<T>(lock: &RwLock<T>) -> std::sync::RwLockWriteGuard<'_, T> {
316    lock.write()
317        .unwrap_or_else(std::sync::PoisonError::into_inner)
318}