commit 56987d99d9568d219d9b791a0422c619a64a6f43
parent 7490143289a082fd35b3fd13f0199c8b5153e907
Author: William Casarin <jb55@jb55.com>
Date: Wed, 18 Feb 2026 10:36:39 -0800
fix zombie deleted sessions from out-of-order replaceable event batches
When multiple revisions of the same kind-31988 session state arrive in
one poll batch (e.g. after relay reconnect), an older non-deleted
revision could be processed after the newer deleted one, creating a
phantom session. Deduplicate the batch by d-tag before processing so
only the latest revision per session is used.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat:
2 files changed, 29 insertions(+), 1 deletion(-)
diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs
@@ -1372,6 +1372,10 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
Err(_) => return,
};
+ // Deduplicate: when multiple revisions of the same session arrive
+ // in one batch (e.g. after relay reconnect), only process the latest.
+ let deduped = session_loader::dedup_by_d_tag(ctx.ndb, &txn, ¬e_keys);
+
// Collect existing claude session IDs to avoid duplicates
let mut existing_ids: std::collections::HashSet<String> = self
.session_manager
@@ -1383,7 +1387,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
})
.collect();
- for key in note_keys {
+ for key in deduped {
let Ok(note) = ctx.ndb.get_note_by_key(&txn, key) else {
continue;
};
diff --git a/crates/notedeck_dave/src/session_loader.rs b/crates/notedeck_dave/src/session_loader.rs
@@ -72,6 +72,30 @@ pub fn query_replaceable_filtered(
}
}
+/// Deduplicate a batch of note keys by `d` tag, keeping only the one with
+/// the highest `created_at` for each unique d-tag value. Useful when
+/// `poll_for_notes` returns multiple revisions of the same replaceable event.
+pub fn dedup_by_d_tag(ndb: &Ndb, txn: &Transaction, keys: &[NoteKey]) -> Vec<NoteKey> {
+ let mut best: std::collections::HashMap<String, (u64, NoteKey)> =
+ std::collections::HashMap::new();
+ for key in keys {
+ let Ok(note) = ndb.get_note_by_key(txn, *key) else {
+ continue;
+ };
+ let Some(d) = get_tag_value(¬e, "d") else {
+ continue;
+ };
+ let ts = note.created_at();
+ if let Some((existing_ts, _)) = best.get(d) {
+ if ts <= *existing_ts {
+ continue;
+ }
+ }
+ best.insert(d.to_string(), (ts, *key));
+ }
+ best.into_values().map(|(_, key)| key).collect()
+}
+
/// Result of loading session messages, including threading info for live events.
pub struct LoadedSession {
pub messages: Vec<Message>,