notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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:
Mcrates/notedeck_dave/src/lib.rs | 6+++++-
Mcrates/notedeck_dave/src/session_loader.rs | 24++++++++++++++++++++++++
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, &note_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(&note, "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>,