fix(audit): round-2 deadlock, lock-contention, trigger-clobber (swarm)

Verified main-compatible moderate fixes from the complete sweep:
- predictive_retrieval get_proactive_suggestions: clone session_context and
  drop the read guard before predict_needed_memories re-acquires it (re-entrant
  RwLock read can deadlock when a writer is queued between)
- hippocampal_index create_semantic_associations: snapshot (id, summary) pairs
  under the read lock, drop it, THEN run the O(n) cosine scan (was blocking all
  writers for the full scan duration)
- prospective_memory check_triggers: don't auto-expire an intention that was
  JUST triggered this iteration (the expire ran unconditionally after
  mark_triggered, clobbering a fresh trigger)

core 477/0, clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-06-28 16:17:38 -05:00
parent 81e808dfcb
commit 0b368b7e58
3 changed files with 32 additions and 17 deletions

View file

@ -1976,15 +1976,22 @@ impl HippocampalIndex {
return Ok(0);
}
// Find similar memories
let mut candidates: Vec<(String, f32)> = Vec::new();
for (id, index) in indices.iter() {
if id == memory_id || index.semantic_summary.is_empty() {
continue;
}
// Snapshot the (id, summary) pairs under the read lock, then DROP the lock
// before the O(n) cosine scan. Holding the read lock across the whole scan
// blocks all writers (index_memory/add_association/migrate_node) for its
// full duration on a large index.
let source_summary = source.semantic_summary.clone();
let pairs: Vec<(String, Vec<f32>)> = indices
.iter()
.filter(|(id, index)| id.as_str() != memory_id && !index.semantic_summary.is_empty())
.map(|(id, index)| (id.clone(), index.semantic_summary.clone()))
.collect();
drop(indices); // release read lock BEFORE the expensive scan
let similarity =
self.cosine_similarity(&source.semantic_summary, &index.semantic_summary);
// Find similar memories (lock-free)
let mut candidates: Vec<(String, f32)> = Vec::new();
for (id, summary) in &pairs {
let similarity = self.cosine_similarity(&source_summary, summary);
if similarity >= similarity_threshold {
candidates.push((id.clone(), similarity));
}
@ -1994,8 +2001,6 @@ impl HippocampalIndex {
candidates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
candidates.truncate(max_associations);
drop(indices); // Release read lock
// Add associations
let mut added = 0;
for (target_id, strength) in candidates {

View file

@ -932,12 +932,19 @@ impl PredictiveMemory {
/// Get proactive suggestions ("You might also need...")
pub fn get_proactive_suggestions(&self, min_confidence: f64) -> Result<Vec<PredictedMemory>> {
let model = self
.user_model
.read()
.map_err(|e| PredictiveMemoryError::LockPoisoned(e.to_string()))?;
// Clone the context and DROP the read guard before calling
// predict_needed_memories, which re-acquires user_model.read(). A
// re-entrant read on the same thread can deadlock under std::sync::RwLock
// when a writer is queued between the two acquisitions.
let session_context = {
let model = self
.user_model
.read()
.map_err(|e| PredictiveMemoryError::LockPoisoned(e.to_string()))?;
model.session_context.clone()
};
let predictions = self.predict_needed_memories(&model.session_context)?;
let predictions = self.predict_needed_memories(&session_context)?;
Ok(predictions
.into_iter()

View file

@ -1279,6 +1279,7 @@ impl ProspectiveMemory {
}
// Check if triggered
let mut just_triggered = false;
if intention
.trigger
.is_triggered(context, &context.recent_events)
@ -1286,6 +1287,7 @@ impl ProspectiveMemory {
{
intention.mark_triggered();
triggered.push(intention.clone());
just_triggered = true;
}
// Check for deadline escalation
@ -1296,8 +1298,9 @@ impl ProspectiveMemory {
}
}
// Auto-expire overdue intentions
if self.config.auto_expire && intention.is_overdue() {
// Auto-expire overdue intentions — but never clobber one we JUST
// triggered this iteration (it should fire before it expires).
if self.config.auto_expire && intention.is_overdue() && !just_triggered {
intention.status = IntentionStatus::Expired;
}
}