From f7888b4a0be3a8bcdb94352383a57b03195600c3 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 9 Nov 2026 13:52:10 -0600 Subject: [PATCH 01/10] fix(bench): edit a stale card closed instead of deleting it A phone that lost its socket across a delete keeps showing the card, the relay refuses a tap on it and the phone's code ignores the refusal, so the tap vanishes with nothing said. An untouched re-drop or a card no longer asked now stays, as a kept one does: its first line says a tap doesn't count, its seeds come off and a tap on it gets one reply. Publish (b) no longer calls the relay, so nothing there can fail a tick. The edit, its lookup and any failure are the old posts' tick's, on the same budgets, and on the board under "Old cards". Claude-Account: russelledeming@gmail.com Co-Authored-By: Claude Opus 5.5 --- desktop/src-tauri/src/bench/item.rs | 6 +- desktop/src-tauri/src/bench/mod.rs | 4 +- desktop/src-tauri/src/bench/publish.rs | 67 ++----- desktop/src-tauri/src/bench/replaced.rs | 65 ++++-- desktop/src-tauri/src/bench/tests.rs | 29 ++- desktop/src-tauri/src/bench/tests/answers.rs | 66 ++++-- desktop/src-tauri/src/bench/tests/capacity.rs | 6 +- desktop/src-tauri/src/bench/tests/channels.rs | 8 +- desktop/src-tauri/src/bench/tests/one_tap.rs | 6 +- desktop/src-tauri/src/bench/tests/replaced.rs | 189 +++++++++++++++++- desktop/src-tauri/src/bench/tests/routes.rs | 4 +- 11 files changed, 321 insertions(+), 129 deletions(-) diff --git a/desktop/src-tauri/src/bench/item.rs b/desktop/src-tauri/src/bench/item.rs index 877dcd38d5c..771c50a2b53 100644 --- a/desktop/src-tauri/src/bench/item.rs +++ b/desktop/src-tauri/src/bench/item.rs @@ -318,7 +318,7 @@ pub(crate) struct Item { /// next one posts without the p tag. A new Drop starts false. #[serde(default)] pub quiet_repost: bool, - /// Its card left the relay (retired for a refresh, deleted by hand) and + /// Its card is gone (retired for a refresh, deleted by hand) and /// has not posted again: it keeps its `MAX_CARDS` place till it does. #[serde(default)] pub repost: bool, @@ -1013,8 +1013,8 @@ pub(crate) fn render_card(item: &Item, hhmm: &str) -> String { out } -/// A stale card kept for what is on it (publish (b)), from its text as -/// posted: closed, it no longer says how to answer, and says where to now. +/// An old post (`replaced`), from what it shows: closed, it no longer says how +/// to answer, and says where to now. pub(crate) fn render_closed(posted: &str) -> String { let open = [ format!(" {TAP}"), diff --git a/desktop/src-tauri/src/bench/mod.rs b/desktop/src-tauri/src/bench/mod.rs index 9ac64faffba..f8ede788a82 100644 --- a/desktop/src-tauri/src/bench/mod.rs +++ b/desktop/src-tauri/src/bench/mod.rs @@ -142,8 +142,8 @@ pub(crate) struct BenchState { /// Each bound channel's join or admin check: cards go only where it passed. channels: BTreeMap, /// Engaged or owner-told cards a new question left behind, and stale ones - /// closed for what is on them: on the relay for good, never adopted again - /// by recover (which would retire them as stale). + /// retired: on the relay for good, never adopted again by recover (which + /// would retire them as stale). 64 bytes each, a few a day at 4 Nov 2026. kept: Vec, /// Each kept card posted in a thread, as (its root, its created_at): it /// still ends the root-reply window of an older card there. diff --git a/desktop/src-tauri/src/bench/publish.rs b/desktop/src-tauri/src/bench/publish.rs index 32f319e625b..4f09c17fe49 100644 --- a/desktop/src-tauri/src/bench/publish.rs +++ b/desktop/src-tauri/src/bench/publish.rs @@ -8,9 +8,8 @@ use std::sync::{Mutex, PoisonError}; use super::channels::{card_channel, channel_ready, joined, reads_replies}; use super::{ - item, live_card, local_hhmm, next_ts, outbox, post_as_writer, query, query_writer, same_drop, - save_both, spend, submit, BenchState, Ctx, Outcome, Posted, BOARD_MAX_AGE_SECS, MAX_CARDS, - RECOVER_LIMIT, + item, live_card, local_hhmm, next_ts, outbox, post_as_writer, query, same_drop, save_both, + spend, submit, BenchState, Ctx, Outcome, Posted, BOARD_MAX_AGE_SECS, MAX_CARDS, RECOVER_LIMIT, }; use crate::events; @@ -337,7 +336,7 @@ fn df_answer(parts: [&str; 3]) -> Result { nostr::Tag::parse(["df-answer", n, by, id]).map_err(|e| e.to_string()) } -/// Whether a recovered card has anything on it but the writer's own seeds: +/// Whether a recovered orphan has anything on it but the writer's own seeds: /// a tap, a reply, a route notice, or a Recorded or receipt edit. A cut page /// holds more than the four seeds, so it counts too. async fn touched( @@ -360,34 +359,6 @@ async fn touched( })) } -/// A stale card kept for what is on it (`touched`), for good: edited closed -/// from its own text as posted (`replaced::old_post`), its thread root (its -/// parent) ending older cards' root-reply windows there, and watched for a tap -/// (`replaced`). One gone from the relay is only kept. -async fn close( - ctx: &Ctx<'_>, - s: &mut BenchState, - id: &str, - ch: &str, - card: &Posted, - now: u64, -) -> Result<(), String> { - let filter = serde_json::json!({ "ids": [card.event_id], "kinds": [9], "#h": [ch] }); - if let Some(e) = query_writer(ctx, s, filter, now).await?.first() { - let text = super::replaced::old_post(s, id, &e.content); - let text = text + &item::thread_line(ch, &card.event_id); - match edit(ctx, s, ch, &card.event_id, &text, now).await { - Err(err) if !err.contains("not found") => return Err(err), - _ => {} - } - let root = item::parent(e).map(|r| (r.to_string(), card.created_at)); - s.kept_at.extend(root); - super::replaced::closed(s, id, ch, card, item::parent(e), now); - } - s.kept.push(card.event_id.clone()); - Ok(()) -} - /// A thread the relay will not take: its root is gone, in another channel or /// not a thread root. fn bad_thread(e: &str) -> bool { @@ -397,9 +368,10 @@ fn bad_thread(e: &str) -> bool { } /// Cards, seeds, then the board, at most `budget` posts. A card is posted -/// once per Drop and card format; it is edited to record an answer and, once -/// closed out, to become a receipt. Only a card nobody engaged with is ever -/// deleted. Every post made once a card's own id is known links its thread. +/// once per Drop and card format; it is edited to record an answer, once closed +/// out to become a receipt and once stale to become an old post (`replaced`), so +/// only a recovered orphan nobody touched is ever deleted. Every post made once +/// a card's own id is known links its thread. /// An owner's notice that fails to go again lands in `failed`, for /// lastError, and the rest goes on. #[allow(clippy::too_many_arguments)] @@ -704,12 +676,13 @@ pub(super) async fn publish( s.items.get_mut(&id).expect("routed id").routed_reply = Some(r.event_id.clone()); } - // (b) Delete cards that are stale (another Drop, card format or stamp) or + // (b) Retire cards that are stale (another Drop, card format or stamp) or // no longer asked (superseded, expired, no longer pending), never for // MAX_CARDS, unless someone answered or replied on them or they are a - // receipt: those stay on the relay for good, and so, closed, does one - // with anything else on it but its seeds. A card in a channel that is - // not ready, or `held`, waits: its taps are read once it is again. + // receipt: those keep their place. A retired card stays on the relay for + // good, kept from recover and owed its edit closed (`replaced`), so no + // failure here fails the tick. A card in a channel that is not ready, or + // `held`, waits: its taps are read once it is again. let stale: Vec<(String, String, Posted)> = s .items .values() @@ -722,20 +695,8 @@ pub(super) async fn publish( }) .collect(); for (id, ch, card) in stale { - // One post per card, its lookups riding on it: the delete, or the edit - // that closes it. - if !spend(budget, 1) { - return Ok(()); - } - // A reply or tap the item never read or no longer holds (free text on - // an unscoped card with no route, or one asked again) still makes the - // card a record: it stays, as `carry_card` keeps one, but closed, - // since nothing reads it any more. - if touched(ctx, s, &ch, &card.event_id, now).await? { - close(ctx, s, &id, &ch, &card, now).await?; - } else { - delete(ctx, s, &ch, &card.event_id, now).await?; - } + super::replaced::retired(s, &id, &ch, &card, now); + s.kept.push(card.event_id); forget_card(ctx, s, &id); } diff --git a/desktop/src-tauri/src/bench/replaced.rs b/desktop/src-tauri/src/bench/replaced.rs index d1ced82d268..f340180bd4e 100644 --- a/desktop/src-tauri/src/bench/replaced.rs +++ b/desktop/src-tauri/src/bench/replaced.rs @@ -7,6 +7,10 @@ //! card that was shown, so it never answers, never reaches the outbox and //! never moves to the new card. //! +//! None is deleted. A phone that lost its socket across the delete keeps +//! showing the post, the relay refuses a tap on it and the phone's code ignores +//! the refusal, so that tap would vanish with nothing said. +//! //! Nothing here fails a tick: lastError stops the steward kit's approvals, and //! the live cards are fine. What keeps failing is on the board (`alarm`). @@ -30,10 +34,11 @@ const NOT_ASKED: &str = "No longer asked. A tap here doesn't count."; const WATCH_SECS: u64 = 86_400; /// Old posts read for taps at once, newest first. One batched /query per tick /// while any is watched, however many: +30/min of the relay's 300/min (about -/// 5/s), beside `poll_answers`' up to 120. Sized for ten times the churn at 1 -/// Nov 2026 (28 drops in a day, 7 cards kept in all, most re-drops deleted as -/// untouched). A cut page keeps its newest events, the ones looked for. Past -/// this the oldest go unread and the board says so (`alarm`). +/// 5/s), beside `poll_answers`' up to 120. Sized for three times the busiest day +/// at 1 Nov 2026, 28 drops if each left an old post; ten times that outgrows it. +/// A cut page keeps its newest events, the ones looked for. Past this the oldest +/// go unread and the board says so (`alarm`); raising it only lengthens the one +/// query's `#e`. pub(super) const WATCH_MAX: usize = 100; /// An old post that would not update is tried again this soon. const RETRY_SECS: u64 = 60; @@ -60,6 +65,10 @@ pub(crate) struct Replaced { pub at: u64, /// The item as the post shows it, until the post is edited. pub shown: Option>, + /// Owed its edit, before its seeds, from its own text on the relay: its + /// item already holds the newer Drop (`retired`). + #[serde(default)] + pub from_post: bool, pub seeds_off: bool, /// Read for a tap: the newest old post of its item, not told yet. pub watch: bool, @@ -83,6 +92,7 @@ impl Replaced { root: root.map(str::to_string), at: now, shown: None, + from_post: false, seeds_off: false, watch: true, seen: Vec::new(), @@ -107,8 +117,8 @@ fn add(s: &mut BenchState, r: Replaced) { } /// A new Drop left the card of the item it replaces behind (`carry_card`): it -/// has no card, the item still in state does. publish edits it from what it -/// shows, its Recorded line or receipt included. +/// has no card, the item still in state does. Edited from what it shows, its +/// Recorded line or receipt included. pub(super) fn left_behind(s: &mut BenchState, new: &item::Item, now: u64) { let Some(old) = s.items.get(&new.id).filter(|_| new.card.is_none()) else { return; @@ -124,16 +134,14 @@ pub(super) fn left_behind(s: &mut BenchState, new: &item::Item, now: u64) { add(s, r); } -/// A stale card publish (b) just edited closed (`old_post`). -pub(super) fn closed( - s: &mut BenchState, - id: &str, - ch: &str, - card: &Posted, - root: Option<&str>, - now: u64, -) { - add(s, Replaced::new(id, ch, card, root, now)); +/// A stale card publish (b) just retired: its item holds the newer Drop, so it +/// is edited from its own text as posted. +pub(super) fn retired(s: &mut BenchState, id: &str, ch: &str, card: &Posted, now: u64) { + let r = Replaced { + from_post: true, + ..Replaced::new(id, ch, card, None, now) + }; + add(s, r); } fn asked(i: &item::Item) -> bool { @@ -273,7 +281,7 @@ async fn read(ctx: &Ctx<'_>, s: &mut BenchState, now: u64, owner_set: &[String]) /// never comes back from /query, so this resumes), its one reply. Ok(false): /// the tick's budget or lookups ran out first. The relay is asked before the /// reply goes, so a crash after it landed and before state saved never sends -/// it twice. +/// it twice. A post no longer there is an Err saying "not found" (`tick`). async fn finish( ctx: &Ctx<'_>, s: &mut BenchState, @@ -285,6 +293,24 @@ async fn finish( ) -> Result { let r = s.replaced[i].clone(); let (ch, card) = (&r.channel, &r.card.event_id); + if r.from_post { + if *budget == 0 || !spend(lookups, 1) { + return Ok(false); + } + let filter = serde_json::json!({ "ids": [card], "kinds": [9], "#h": [ch] }); + let posted = query_writer(ctx, s, filter, now).await?; + let e = posted.first().ok_or("not found: no such post")?; + let text = old_post(s, &r.item, &e.content) + &item::thread_line(ch, card); + spend(budget, 1); + edit(ctx, s, ch, card, &text, now).await?; + // Its thread root ends older cards' root-reply windows there. + let root = item::parent(e).map(str::to_string); + s.kept_at + .extend(root.clone().map(|root| (root, r.card.created_at))); + let r = &mut s.replaced[i]; + (r.from_post, r.root) = (false, root); + return Ok(true); + } if let Some(shown) = &r.shown { if !spend(budget, 1) { return Ok(false); @@ -366,6 +392,10 @@ pub(super) async fn tick( hhmm: &str, owner_set: &[String], ) { + // Past 30 failing the loop runs out of lookups every tick and never reaches + // the retain below, so one that has failed goes at its window here. + s.replaced + .retain(|r| r.retry_at == 0 || now < r.at + WATCH_SECS); read(ctx, s, now, owner_set).await; let mut lookups = LOOKUPS_PER_TICK; for i in 0..s.replaced.len() { @@ -374,6 +404,7 @@ pub(super) async fn tick( continue; } match finish(ctx, s, i, budget, &mut lookups, now, hhmm).await { + // Not `break`: the retain would drop what is past its window untried. Ok(false) => return, Ok(true) => s.replaced[i].retry_at = 0, // Gone from the relay: nothing is left to edit or to reply under. diff --git a/desktop/src-tauri/src/bench/tests.rs b/desktop/src-tauri/src/bench/tests.rs index 71c3e4d2b21..3f11c36c697 100644 --- a/desktop/src-tauri/src/bench/tests.rs +++ b/desktop/src-tauri/src/bench/tests.rs @@ -776,6 +776,8 @@ struct Relay { clock: Mutex>, /// A /query naming this id in its `#e` fails with a 500. e_down: Mutex>, + /// A write on this id, or a /query for it by `ids`, fails with a 500. + post_down: Mutex>, } fn tag_value(ev: &Value, name: &str) -> Option { @@ -823,6 +825,9 @@ async fn fake_events( return (StatusCode::OK, Json(reply)); } let target = tag_value(&ev, "e").unwrap_or_default(); + if r.post_down.lock().unwrap().as_ref() == Some(&target) { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({}))); + } if matches!(kind, 5 | 9 | 40003) && r.gone.lock().unwrap().contains(&target) { let message = match kind { 5 => "target event not found", @@ -865,6 +870,10 @@ async fn fake_query( if down.is_some_and(|id| q[0]["#e"].as_array().is_some_and(|e| e.contains(&id))) { return Err(StatusCode::INTERNAL_SERVER_ERROR); } + let down = r.post_down.lock().unwrap().clone().map(|id| json!(id)); + if down.is_some_and(|id| q[0]["ids"].as_array().is_some_and(|e| e.contains(&id))) { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } let mut evs = r.query_reply.lock().unwrap().clone(); if let Some(authors) = q[0]["authors"].as_array() { evs.retain(|e| authors.contains(&e["pubkey"])); @@ -938,6 +947,7 @@ impl Bench { admins_down: Mutex::new(false), clock: Mutex::new(None), e_down: Mutex::new(None), + post_down: Mutex::new(None), }); let base = fake_relay(relay.clone()).await; let state: &'static AppState = Box::leak(Box::new(crate::app_state::build_app_state())); @@ -1108,9 +1118,11 @@ async fn resync_is_idempotent() { } #[tokio::test] -async fn pending_reword_deletes_and_reposts_never_edits() { +async fn pending_reword_reposts_and_edits_the_old_card_closed() { let mut b = seeded_bench(Gate::Open, &[]).await; let old_card = b.card("orchestrator/relay-choice"); + let old_post = answers::posted(&b, &old_card.event_id); + answers::reply(&b, &[&old_post]); let old_board = b.s.board.clone().unwrap(); let before = b.posts().len(); b.drop_file( @@ -1124,16 +1136,15 @@ async fn pending_reword_deletes_and_reposts_never_edits() { .iter() .map(|(e, _)| e["kind"].as_u64().unwrap()) .collect(); - assert_eq!(kinds, [5, 9, 7, 7, 9, 5]); - assert_eq!(tag_value(&posts[0].0, "e").unwrap(), old_card.event_id); + assert_eq!(kinds, [9, 7, 7, 40003, 9, 5]); let new_card = b.card("orchestrator/relay-choice"); assert_ne!(new_card.hash, old_card.hash); assert_eq!( - tag_value(&posts[1].0, "client").unwrap(), + tag_value(&posts[0].0, "client").unwrap(), format!("bench:card:orchestrator/relay-choice@{}", new_card.hash) ); + assert_eq!(tag_value(&posts[3].0, "e").unwrap(), old_card.event_id); assert_eq!(tag_value(&posts[5].0, "e").unwrap(), old_board.event_id); - assert!(!b.kinds().contains(&40003)); } #[tokio::test] @@ -1331,7 +1342,7 @@ async fn vanished_targets_are_dropped_not_retried() { b.settle(2).await; assert_eq!(b.kinds()[before..], [40003, 9]); assert_ne!(b.s.board.as_ref().unwrap().event_id, old_board.event_id); - // A hard-purged card: the retiring kind 5 is refused, the repost goes on. + // A hard-purged card: nothing is left to edit, the repost goes on. let old_card = b.card("orchestrator/relay-choice"); b.relay .gone @@ -1345,7 +1356,7 @@ async fn vanished_targets_are_dropped_not_retried() { &drop_json(&[("options", json!(["Stay", "Go"]))]), ); b.settle(3).await; - assert_eq!(b.kinds()[before..], [5, 9, 7, 7, 9, 5]); + assert_eq!(b.kinds()[before..], [9, 7, 7, 9, 5]); assert_ne!( b.card("orchestrator/relay-choice").event_id, old_card.event_id @@ -1382,8 +1393,8 @@ async fn a_tick_never_exceeds_the_post_budget() { b.tick().await.unwrap(); assert!(b.posts().len() - n <= MAX_POSTS_PER_TICK); } - // Delete, card, seed; then the board and its predecessor's delete as a pair. - assert_eq!(b.kinds()[before..], [5, 9, 7, 9, 5]); + // Card, seed; then the board and its predecessor's delete as a pair. + assert_eq!(b.kinds()[before..], [9, 7, 9, 5]); } #[tokio::test] diff --git a/desktop/src-tauri/src/bench/tests/answers.rs b/desktop/src-tauri/src/bench/tests/answers.rs index 261a1811628..79089a9cfd6 100644 --- a/desktop/src-tauri/src/bench/tests/answers.rs +++ b/desktop/src-tauri/src/bench/tests/answers.rs @@ -457,16 +457,15 @@ async fn pending_reword_closes_a_card_someone_replied_or_reacted_on() { &drop_json(&[("options", json!(["Stay", "Move away"]))]), ); b.settle(3).await; - // No delete: the old card is closed, then a new one, its seeds, the - // board. + // No delete: a new card and its seeds, the old one closed, the board. let posts = b.posts()[before..].to_vec(); assert_eq!( b.kinds()[before..], - [40003, 9, 7, 7, 9, 5], + [9, 7, 7, 40003, 9, 5], "heart: {heart}" ); - assert_eq!(tag_value(&posts[0].0, "e").unwrap(), old_card.event_id); - let closed = text(&posts[0].0); + assert_eq!(tag_value(&posts[3].0, "e").unwrap(), old_card.event_id); + let closed = text(&posts[3].0); assert!(closed.contains("**Relay choice**\n\nStay or move?\n\n1. Stay\n2. Move\n\nas of")); assert!(!closed.contains("Tap one number"), "{closed}"); assert!(closed.contains("\n\nClosed: a tap or reply here is no longer read.")); @@ -479,30 +478,52 @@ async fn pending_reword_closes_a_card_someone_replied_or_reacted_on() { } } +/// The worst case at once: every card asked again in one tick. New cards go +/// first, each tick stays inside the post budget and one lookup, each old post +/// is edited once and none deleted. #[tokio::test] -async fn stale_cards_are_looked_up_within_the_post_budget() { +async fn cards_asked_again_at_once_stay_inside_the_budgets() { let mut b = seeded_bench(Gate::Open, &[]).await; let ids = ["a", "b", "c", "d", "relay-choice"]; for id in &ids[..4] { b.drop_file("orchestrator", id, &drop_json(&[("title", json!(id))])); } b.settle(10).await; - assert!(ids + let cards: Vec = ids .iter() - .all(|id| b.s.items[&format!("orchestrator/{id}")].card.is_some())); + .map(|id| b.card(&format!("orchestrator/{id}")).event_id) + .collect(); + let old: Vec = cards.iter().map(|c| posted(&b, c)).collect(); + reply(&b, &old.iter().collect::>()); // All five asked again at once, against four posts a tick. for id in ids { let title = json!(format!("{id}, reworded")); b.drop_file("orchestrator", id, &drop_json(&[("title", title)])); } - let (posts, queries) = (b.posts().len(), b.relay.queries.lock().unwrap().len()); - b.tick().await.unwrap(); - assert_eq!(b.kinds()[posts..], [5, 5, 5, 5]); - let lookups = b.relay.queries.lock().unwrap()[queries..] - .iter() - .filter(|q| q[0]["kinds"] == json!([7, 9, 40003])) - .count(); - assert_eq!(lookups, 4); + let start = b.posts().len(); + let writer = json!([b.writer_hex()]); + for tick in 0..24 { + let (posts, queries) = (b.posts().len(), b.relay.queries.lock().unwrap().len()); + b.tick().await.unwrap(); + assert!(b.posts().len() - posts <= MAX_POSTS_PER_TICK); + let q = b.relay.queries.lock().unwrap()[queries..].to_vec(); + let lookup = |q: &&Value| q[0]["authors"] == writer || q[0]["ids"].is_array(); + assert!(q.iter().filter(lookup).count() <= 1); + if tick == 0 { + assert_eq!(b.kinds()[posts..], [9, 9, 9, 9]); + } + } + let posts = b.posts()[start..].to_vec(); + for card in &cards { + let on = |e: &&(Value, Option)| tag_value(&e.0, "e").as_ref() == Some(card); + let kinds: Vec = posts + .iter() + .filter(on) + .map(|(e, _)| e["kind"].as_u64().unwrap()) + .collect(); + assert_eq!(kinds, [40003], "{card}"); + } + assert_eq!(b.s.kept, cards); } #[tokio::test] @@ -740,19 +761,20 @@ async fn older_card_format_case(older: &str) { let answer = b.s.items[ITEM].answer.clone(); assert!(answer.is_some()); // The tapped card takes the new text by its Recorded edit; only the - // unanswered one is refreshed: delete, quiet repost, seeds, board pair. + // unanswered one is refreshed: quiet repost, seeds, board pair. No post of + // the old one is on this fake relay to edit. let posts = b.posts()[before..].to_vec(); - assert_eq!(b.kinds()[before..], [40003, 5, 9, 7, 7, 9, 5]); + assert_eq!(b.kinds()[before..], [40003, 9, 7, 7, 9, 5]); assert_eq!(tag_value(&posts[0].0, "e").unwrap(), kept); - assert_eq!(tag_value(&posts[1].0, "e").unwrap(), old); + assert_eq!(b.s.kept, [old]); let new_card = b.card(OTHER); assert_eq!( - tag_value(&posts[2].0, "client").unwrap(), + tag_value(&posts[1].0, "client").unwrap(), format!("bench:card:{OTHER}@{}", card_hash(&b.s.items[OTHER])) ); assert!(text(&posts[0].0).contains("\n**Relay choice**\n")); - assert!(text(&posts[2].0).contains("\n**Other**\n") && text(&posts[2].0).contains(HINT)); - assert_eq!(tag_value(&posts[2].0, "p"), None); + assert!(text(&posts[1].0).contains("\n**Other**\n") && text(&posts[1].0).contains(HINT)); + assert_eq!(tag_value(&posts[1].0, "p"), None); assert!(!b.s.items[OTHER].quiet_repost); // The answered card, its answer and its outbox file stay. assert_eq!(b.card(ITEM).event_id, kept); diff --git a/desktop/src-tauri/src/bench/tests/capacity.rs b/desktop/src-tauri/src/bench/tests/capacity.rs index 7d842454e08..64de11aa4e0 100644 --- a/desktop/src-tauri/src/bench/tests/capacity.rs +++ b/desktop/src-tauri/src/bench/tests/capacity.rs @@ -202,8 +202,8 @@ async fn a_card_retired_for_a_refresh_keeps_its_place() { b.drop_file("orchestrator", "a-first", &attention); b.settle(2).await; assert_eq!(queued(&b), ["a-first", "hold-36", "hold-37", "hold-38"]); - // Four cards change format at once: (b) spends the tick retiring them, - // and they come back before the newcomer, which still waits. + // Four cards change format at once: (b) retires them, and they come back + // before the newcomer, which still waits. let refreshed = ["hold-32", "hold-33", "hold-34", "hold-35"]; for id in refreshed { let i = b.s.items.get_mut(&format!("orchestrator/{id}")).unwrap(); @@ -211,7 +211,7 @@ async fn a_card_retired_for_a_refresh_keeps_its_place() { i.card.as_mut().unwrap().hash = format!("{hash}.old-format"); } b.tick().await.unwrap(); - assert!(b.s.items["orchestrator/hold-32"].card.is_none()); + assert_eq!(b.s.kept.len(), refreshed.len()); b.settle(4).await; for id in refreshed { let i = &b.s.items[&format!("orchestrator/{id}")]; diff --git a/desktop/src-tauri/src/bench/tests/channels.rs b/desktop/src-tauri/src/bench/tests/channels.rs index ca6d81ab1c1..bdc7373a4ea 100644 --- a/desktop/src-tauri/src/bench/tests/channels.rs +++ b/desktop/src-tauri/src/bench/tests/channels.rs @@ -628,10 +628,10 @@ async fn closed_card_keeps_its_own_thread_root() { b.drop_file("reset-worker", "welcome", &ask(&[("replyTo", json!(r2))])); b.settle(3).await; assert_eq!(b.s.kept, [old.event_id.as_str()]); - assert_eq!(b.s.kept_at, [(r1, old.created_at)]); - let closed = &b.posts()[before].0; - assert_eq!(closed["kind"], 40003); - assert_eq!(tag_value(closed, "e").unwrap(), old.event_id); + assert_eq!(b.s.kept_at, [(r1.clone(), old.created_at)]); + assert_eq!(b.s.replaced[0].root, Some(r1), "where its one reply goes"); + let on_old = |e: &Value| e["kind"] == 40003 && tag_value(e, "e") == Some(old.event_id.clone()); + assert!(b.posts()[before..].iter().any(|(e, _)| on_old(e))); let new = b.card(ASK); assert_ne!(new.event_id, old.event_id); assert_eq!(parent(&posted(&b, &new.event_id)), Some(r2.as_str())); diff --git a/desktop/src-tauri/src/bench/tests/one_tap.rs b/desktop/src-tauri/src/bench/tests/one_tap.rs index f4471580b5a..6077d9fcc37 100644 --- a/desktop/src-tauri/src/bench/tests/one_tap.rs +++ b/desktop/src-tauri/src/bench/tests/one_tap.rs @@ -501,9 +501,9 @@ async fn an_unanswered_card_asked_again_is_replaced_as_today() { b.drop_file("orchestrator", "relay-choice", &drop); }; let replaced = |b: &Bench, before: usize, old: &str| { - assert_eq!(b.kinds()[before..], [5, 9, 7, 7, 9, 5]); - assert_eq!(tag_value(&b.posts()[before].0, "e").as_deref(), Some(old)); - content(&b.posts()[before + 1].0).to_string() + assert_eq!(b.kinds()[before..], [9, 7, 7, 9, 5]); + assert!(b.s.kept.iter().any(|k| k == old)); + content(&b.posts()[before].0).to_string() }; let old = b.card(ITEM).event_id; let before = b.posts().len(); diff --git a/desktop/src-tauri/src/bench/tests/replaced.rs b/desktop/src-tauri/src/bench/tests/replaced.rs index f0540114bf9..71688ecc96e 100644 --- a/desktop/src-tauri/src/bench/tests/replaced.rs +++ b/desktop/src-tauri/src/bench/tests/replaced.rs @@ -7,9 +7,10 @@ use super::channels::X; use super::receipts::close_out; use super::*; use crate::bench::channels::ChannelCheck; -use crate::bench::replaced::{closed, tick as old_posts_tick, WATCH_MAX}; +use crate::bench::replaced::{retired, tick as old_posts_tick, Replaced, WATCH_MAX}; const REPLACED: &str = "Replaced by a newer card. A tap here doesn't count."; +const NOT_ASKED: &str = "No longer asked. A tap here doesn't count."; fn content(ev: &Value) -> &str { ev["content"].as_str().unwrap() @@ -91,6 +92,13 @@ async fn old_tick(b: &mut Bench, mut budget: usize) -> usize { budget } +/// An old post already edited closed. +fn closed(s: &mut BenchState, id: &str, ch: &str, card: &Posted, root: Option<&str>, now: u64) { + retired(s, id, ch, card, now); + let r = s.replaced.last_mut().unwrap(); + (r.from_post, r.root) = (false, root.map(str::to_string)); +} + /// An old post with no history, its id made from `n`, in `ch`. fn old_card(b: &mut Bench, n: usize, ch: &str) -> Posted { let old = Posted { @@ -243,10 +251,7 @@ async fn a_re_ask_edits_the_old_post_first_line_and_takes_its_seeds_off() { async fn a_touched_stale_card_says_so_first_too() { for (drop, first) in [ (vec![("options", json!(["Stay", "Move away"]))], REPLACED), - ( - vec![("validity", json!("superseded"))], - "No longer asked. A tap here doesn't count.", - ), + (vec![("validity", json!("superseded"))], NOT_ASKED), ] { let mut b = seeded_bench(Gate::Open, &[]).await; let old = b.card(ITEM); @@ -290,17 +295,118 @@ async fn an_old_receipt_keeps_its_outcome_time() { assert!(!text.contains("23:59"), "{text}"); } -/// `pending_reword_deletes_and_reposts_never_edits` pins the post; a deleted -/// card has nothing left to say so on, and the relay refuses a tap on it. +/// An untouched card stays as any other: a phone that lost its socket across a +/// delete would keep showing it, and the relay would refuse a tap there with +/// nothing said. It says so from its own text, its seeds come off and a tap on +/// it is told, once. #[tokio::test] -async fn an_untouched_stale_card_is_only_deleted() { +async fn an_untouched_stale_card_is_edited_not_deleted() { let mut b = seeded_bench(Gate::Open, &[]).await; let old = b.card(ITEM).event_id; + let on_card = seeds(&b, &old); + let post = posted(&b, &old); + let mut relay: Vec<&nostr::Event> = on_card.iter().collect(); + relay.push(&post); + reply(&b, &relay); redrop(&b, "Relay choice, reworded"); + b.settle(4).await; + + let edited = last_edit(&b, &old); + let text = content(&edited); + assert!(text.starts_with(&format!("{REPLACED}\n馃敶 路 ")), "{text}"); + assert!(text.contains("**Relay choice**"), "as asked: {text}"); + assert!(!text.contains("reworded"), "{text}"); + assert!(text.ends_with(&thread_line(&b.channel(), &old)), "{text}"); + let off = removed(&b); + assert!(!off.contains(&old), "deleted: {off:?}"); + for seed in &on_card { + assert!(off.contains(&seed.id.to_hex()), "{off:?}"); + } + assert_eq!(b.s.kept, [old.as_str()]); + + let stray = tap(&b.ctx.owner, &old, OPTION_EMOJI[0], b.now + 1); + reply(&b, &[&stray]); b.settle(3).await; - assert!(removed(&b).contains(&old)); - assert!(b.s.replaced.is_empty()); - assert!(b.s.kept.is_empty()); + assert_eq!(replies(&b).len(), 1); + assert!(content(&replies(&b)[0]).contains("The live card is the one that counts:")); +} + +/// lastError stops the kit's approvals: a lookup that fails, an edit refused or +/// a post gone from the relay is the board's and the retry's, never the tick's. +#[tokio::test] +async fn a_stale_card_that_will_not_update_fails_no_tick() { + let mut b = seeded_bench(Gate::Open, &[]).await; + let old = b.card(ITEM).event_id; + let post = posted(&b, &old); + reply(&b, &[&post]); + *b.relay.post_down.lock().unwrap() = Some(old.clone()); + redrop(&b, "Relay choice, reworded"); + b.tick().await.unwrap(); + assert!(b.s.replaced[0].from_post); + // Saved by a build before this one, the key is absent and state still loads. + let mut saved = serde_json::to_value(&b.s.replaced[0]).unwrap(); + saved.as_object_mut().unwrap().remove("fromPost"); + assert!(!serde_json::from_value::(saved).unwrap().from_post); + let retry = b.s.replaced[0].retry_at; + assert_eq!(retry, b.now + 60); + b.settle(2).await; + assert!( + board(&b).contains("\n\nOld cards\nWould not update: 1."), + "{}", + board(&b) + ); + assert!(b.s.replaced[0].from_post, "not before its retry"); + *b.relay.post_down.lock().unwrap() = None; + b.now = retry; + b.settle(4).await; + assert!(content(&last_edit(&b, &old)).starts_with(REPLACED)); + assert!(!board(&b).contains("Old cards"), "{}", board(&b)); + + // One gone from the relay is let go, with nothing to edit. + let gone = b.card(ITEM).event_id; + reply(&b, &[]); + let before = b.posts().len(); + redrop(&b, "Relay choice, reworded again"); + b.settle(3).await; + assert!(!b.s.replaced.iter().any(|r| r.card.event_id == gone)); + let edited = |e: &Value| e["kind"] == 40003 && tag_value(e, "e") == Some(gone.clone()); + assert!(!b.posts()[before..].iter().any(|(e, _)| edited(e))); + assert!(b.s.kept.contains(&gone)); +} + +/// Nothing an old post does brings a new card back: edited, kept and read again +/// through a relaunch, the item keeps the one newer card it was given. +#[tokio::test] +async fn an_old_post_never_brings_a_repost() { + let mut b = seeded_bench(Gate::Open, &[]).await; + let old = b.card(ITEM).event_id; + let post = posted(&b, &old); + reply(&b, &[&post]); + redrop(&b, "Relay choice, reworded"); + b.settle(6).await; + let new = b.card(ITEM).event_id; + assert_ne!(new, old); + let board_id = b.s.board.as_ref().unwrap().event_id.clone(); + let (new_post, board_post) = (posted(&b, &new), posted(&b, &board_id)); + let before = b.posts().len(); + b.settle(10).await; + assert_eq!(b.posts().len(), before, "quiet once it is done"); + + // A relaunch reads both posts: the old one is kept, so never adopted. + reply(&b, &[&post, &new_post, &board_post]); + b.s.needs_rebuild = true; + b.settle(10).await; + assert_eq!(b.card(ITEM).event_id, new); + assert!(b.s.orphan_cards.is_empty() && b.s.strays.is_empty()); + assert_eq!(b.posts().len(), before, "no repost, no delete"); + + // State lost: the newest post is the card, the old one only listed. + b.s.kept.clear(); + b.s.needs_rebuild = true; + b.settle(10).await; + assert_eq!(b.card(ITEM).event_id, new); + assert_eq!(b.s.strays, [old]); + assert_eq!(b.posts().len(), before, "no repost, no delete"); } #[tokio::test] @@ -623,6 +729,56 @@ async fn removals_and_replies_stop_at_the_post_budget() { assert!(b.s.replaced[1].tapped.is_some()); } +/// One lookup a tick and a minute's retry: with more posts failing than a +/// minute's lookups, every tick runs out. They still go at the end of their +/// window. +#[tokio::test] +async fn posts_failing_past_the_lookups_still_go_at_their_window() { + let mut b = seeded_bench(Gate::Open, &[]).await; + let ch = b.channel(); + let seeds: Vec<_> = (1..=40) + .map(|n| { + let old = old_card(&mut b, n, &ch); + tap(&b.ctx.writer, &old.event_id, OPTION_EMOJI[0], b.now) + }) + .collect(); + reply(&b, &seeds.iter().collect::>()); + let end = b.now + 86_400; + *b.relay.clock.lock().unwrap() = Some(b.now + 10_000); + for _ in 0..120 { + b.now += 2; + old_tick(&mut b, 4).await; + } + assert_eq!(b.s.replaced.len(), 40); + b.now = end; + // The 30 tried go at once; the 10 waiting behind them go a tick each. + for _ in 0..10 { + b.now += 2; + old_tick(&mut b, 4).await; + } + assert!(b.s.replaced.is_empty()); +} + +/// Out of lookups, a post past its window that hasn't failed waits for its try: +/// a Bench off for a day still closes what it owed. +#[tokio::test] +async fn past_its_window_a_post_still_gets_its_try_when_lookups_run_out() { + let mut b = seeded_bench(Gate::Open, &[]).await; + let ch = b.channel(); + let seeds = [1, 2].map(|n| { + let old = old_card(&mut b, n, &ch); + tap(&b.ctx.writer, &old.event_id, OPTION_EMOJI[0], b.now) + }); + reply(&b, &[&seeds[0], &seeds[1]]); + b.now += 86_400; + old_tick(&mut b, 4).await; + assert_eq!(removed(&b).len(), 1); + assert_eq!(b.s.replaced.len(), 2); + old_tick(&mut b, 4).await; + assert_eq!(removed(&b).len(), 2); + assert!(b.s.replaced.is_empty()); +} + /// With no post left to spend, the lookups before a removal or a reply wait too. #[tokio::test] async fn no_budget_no_lookup() { @@ -664,6 +820,17 @@ async fn lookups_are_capped_per_tick() { assert_eq!(lookups(&b, from), 1); } +/// Its lookup is a lookup and its edit a post, like any other. +#[tokio::test] +async fn an_edit_from_its_post_spends_a_post() { + let mut b = seeded_bench(Gate::Open, &[]).await; + let (ch, card) = (b.channel(), b.card(ITEM)); + let post = posted(&b, &card.event_id); + reply(&b, &[&post]); + retired(&mut b.s, "orchestrator/old", &ch, &card, b.now); + assert_eq!(old_tick(&mut b, 4).await, 3); +} + /// A read that keeps failing fails no tick (lastError stops the kit's approvals). /// Past a minute the board says taps go unanswered, until a read goes through. #[tokio::test] diff --git a/desktop/src-tauri/src/bench/tests/routes.rs b/desktop/src-tauri/src/bench/tests/routes.rs index 5c1e9cf6883..17143feb63c 100644 --- a/desktop/src-tauri/src/bench/tests/routes.rs +++ b/desktop/src-tauri/src/bench/tests/routes.rs @@ -778,8 +778,8 @@ async fn a_reword_closes_a_routed_card_with_only_a_reply() { let reworded = drop_json(&[("options", json!(["Stay", "Move away"]))]); b.drop_file("orchestrator", "relay-choice", &reworded); b.settle(3).await; - assert_eq!(b.kinds()[before..], [40003, 9, 7, 7, 9, 5]); - let closed = &b.posts()[before].0; + assert_eq!(b.kinds()[before..], [9, 7, 7, 40003, 9, 5]); + let closed = &b.posts()[before + 3].0; assert_eq!(tag_value(closed, "e").unwrap(), old_card.event_id); assert!(content(closed).contains("\n\nClosed: a tap or reply here is no longer read.")); assert_eq!(b.s.kept, [old_card.event_id]); From 709f92094c3fe451f6938f8faf5597da9edac8ab Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 9 Nov 2026 14:11:43 -0600 Subject: [PATCH 02/10] fix(desktop): keep the outsider notice to pasted keys After a key had been pasted once, the community roster stayed cached. A plain search or malformed key that matched nobody then showed "That key isn't a member of this community yet" instead of "No matching people or agents." The resolved check never required a parsed key, and the roster lookup for a null key is always false, so no key at all counted as an outsider. Require a parsed key before treating the lookup as resolved. Wording only: nothing could be added by mistake. Claude-Account: russelledeming@gmail.com Co-Authored-By: Claude Opus 5.5 --- .../features/channels/ui/MembersSidebar.tsx | 1 + desktop/tests/e2e/channels.spec.ts | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 9a4e56b363f..bfe1b5823c0 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -283,6 +283,7 @@ export function MembersSidebar({ ? pastedProfileQuery.data?.profiles[pastedPubkey] : undefined; const pastedKeyResolved = + pastedPubkey !== null && pastedProfileQuery.isSuccess && !pastedProfileQuery.isFetching && !rosterQuery.isLoading; diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index c193b09e97e..d7ea18dab1e 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -4787,6 +4787,26 @@ test("members sidebar offers nothing for a malformed key", async ({ page }) => { await expect(page.getByText("No matching people or agents.")).toBeVisible(); }); +test("members sidebar keeps the outsider notice to pasted keys once the roster is cached", async ({ + page, +}) => { + await page.goto("/"); + await openMembersSidebar(page, "general"); + + const search = page.getByTestId("channel-management-search-users"); + const outsiderNotice = page.getByText( + "That key isn鈥檛 a member of this community yet.", + ); + await search.fill(PASTED_NPUB); + await expect(outsiderNotice).toBeVisible(); + + await search.clear(); + await search.fill("zzzz-matches-nobody"); + + await expect(page.getByText("No matching people or agents.")).toBeVisible(); + await expect(outsiderNotice).toHaveCount(0); +}); + test("members sidebar pages add-member search beyond the first 50 people", async ({ page, }) => { From f7630b2135bd3c37cc02badc7857d2f793f24451 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 9 Nov 2026 14:57:51 -0600 Subject: [PATCH 03/10] fix(bench): a retired card nobody touched ends no older card's window Every card retired in a thread added a kept_at entry, including untouched ones main deleted and left no entry for. An older card still open in that thread then stopped taking replies that name only the root, for good, with nothing said. A retired post in a thread now reads what is on it once it is edited, and adds the entry only if there is something besides its seeds and its own closing edit, as main did. It costs one lookup more, on the old posts' one a tick, and only for a post in a thread. Claude-Account: russelledeming@gmail.com Co-Authored-By: Claude Opus 5.5 --- desktop/src-tauri/src/bench/mod.rs | 4 +-- desktop/src-tauri/src/bench/publish.rs | 18 +++++----- desktop/src-tauri/src/bench/replaced.rs | 27 ++++++++++---- desktop/src-tauri/src/bench/tests/channels.rs | 36 +++++++++++++++++++ desktop/src-tauri/src/bench/tests/replaced.rs | 2 +- 5 files changed, 70 insertions(+), 17 deletions(-) diff --git a/desktop/src-tauri/src/bench/mod.rs b/desktop/src-tauri/src/bench/mod.rs index f8ede788a82..322e580993d 100644 --- a/desktop/src-tauri/src/bench/mod.rs +++ b/desktop/src-tauri/src/bench/mod.rs @@ -145,8 +145,8 @@ pub(crate) struct BenchState { /// retired: on the relay for good, never adopted again by recover (which /// would retire them as stale). 64 bytes each, a few a day at 4 Nov 2026. kept: Vec, - /// Each kept card posted in a thread, as (its root, its created_at): it - /// still ends the root-reply window of an older card there. + /// Each kept card in a thread with anything on it, as (its root, its + /// created_at): it still ends the root-reply window of an older card there. kept_at: Vec<(String, u64)>, /// Old posts owed an edit or a reply or watched for a tap; since when their read fails. replaced: Vec, diff --git a/desktop/src-tauri/src/bench/publish.rs b/desktop/src-tauri/src/bench/publish.rs index 4f09c17fe49..192e44a455a 100644 --- a/desktop/src-tauri/src/bench/publish.rs +++ b/desktop/src-tauri/src/bench/publish.rs @@ -336,27 +336,29 @@ fn df_answer(parts: [&str; 3]) -> Result { nostr::Tag::parse(["df-answer", n, by, id]).map_err(|e| e.to_string()) } -/// Whether a recovered orphan has anything on it but the writer's own seeds: -/// a tap, a reply, a route notice, or a Recorded or receipt edit. A cut page -/// holds more than the four seeds, so it counts too. -async fn touched( +/// What a post has on it but the writer's own seeds: a tap, a reply, a route +/// notice, or a Recorded, receipt or closing edit. A cut page holds more than +/// the four seeds, so it counts too. +pub(super) async fn on_post( ctx: &Ctx<'_>, s: &BenchState, ch: &str, card: &str, now: u64, -) -> Result { +) -> Result, String> { let filter = serde_json::json!({ "kinds": [7, 9, 40003], "#h": [ch], "#e": [card], "limit": RECOVER_LIMIT, }); let writer = ctx.writer.public_key(); - Ok(query(ctx, s, filter, now).await?.iter().any(|e| { + let mut on = query(ctx, s, filter, now).await?; + on.retain(|e| { let on_card = e .tags .iter() .any(|t| matches!(t.as_slice(), [k, v, ..] if k == "e" && v == card)); on_card && !(e.kind.as_u16() == 7 && e.pubkey == writer) - })) + }); + Ok(on) } /// A thread the relay will not take: its root is gone, in another channel or @@ -712,7 +714,7 @@ pub(super) async fn publish( continue; } // An answered, replied or receipt card stays on the relay for good. - if touched(ctx, s, &ch, &orphan.event_id, now).await? { + if !on_post(ctx, s, &ch, &orphan.event_id, now).await?.is_empty() { s.kept.push(orphan.event_id.clone()); } else { if !spend(budget, 1) { diff --git a/desktop/src-tauri/src/bench/replaced.rs b/desktop/src-tauri/src/bench/replaced.rs index f340180bd4e..6bce3bf4fc2 100644 --- a/desktop/src-tauri/src/bench/replaced.rs +++ b/desktop/src-tauri/src/bench/replaced.rs @@ -19,7 +19,7 @@ use std::collections::BTreeSet; use serde::{Deserialize, Serialize}; use super::channels::{card_channel, ready}; -use super::publish::{edit, post_message, thread_of}; +use super::publish::{edit, on_post, post_message, thread_of}; use super::{ item, post_as_writer, query, query_writer, spend, BenchState, Ctx, Posted, RECOVER_LIMIT, }; @@ -66,7 +66,8 @@ pub(crate) struct Replaced { /// The item as the post shows it, until the post is edited. pub shown: Option>, /// Owed its edit, before its seeds, from its own text on the relay: its - /// item already holds the newer Drop (`retired`). + /// item already holds the newer Drop (`retired`). With a `root`, edited: + /// what is on it is still to read, for `kept_at`. #[serde(default)] pub from_post: bool, pub seeds_off: bool, @@ -144,6 +145,12 @@ pub(super) fn retired(s: &mut BenchState, id: &str, ch: &str, card: &Posted, now add(s, r); } +/// Whether `e` is an edit that made a post an old post. +pub(super) fn closes(e: &nostr::Event) -> bool { + let first = e.content.lines().next().unwrap_or_default(); + e.kind.as_u16() == 40003 && (first == REPLACED || first == NOT_ASKED) +} + fn asked(i: &item::Item) -> bool { i.kind == item::ItemKind::Pending && i.validity == item::Validity::Current } @@ -297,18 +304,26 @@ async fn finish( if *budget == 0 || !spend(lookups, 1) { return Ok(false); } + // Edited, in a thread: with anything else on it, it ends older cards' + // root-reply windows there, as a kept card does. With nothing, a reply + // naming only the root is still theirs. + if let Some(root) = &r.root { + let on = on_post(ctx, s, ch, card, now).await?; + if on.iter().any(|e| !closes(e)) { + s.kept_at.push((root.clone(), r.card.created_at)); + } + s.replaced[i].from_post = false; + return Ok(true); + } let filter = serde_json::json!({ "ids": [card], "kinds": [9], "#h": [ch] }); let posted = query_writer(ctx, s, filter, now).await?; let e = posted.first().ok_or("not found: no such post")?; let text = old_post(s, &r.item, &e.content) + &item::thread_line(ch, card); spend(budget, 1); edit(ctx, s, ch, card, &text, now).await?; - // Its thread root ends older cards' root-reply windows there. let root = item::parent(e).map(str::to_string); - s.kept_at - .extend(root.clone().map(|root| (root, r.card.created_at))); let r = &mut s.replaced[i]; - (r.from_post, r.root) = (false, root); + (r.from_post, r.root) = (root.is_some(), root); return Ok(true); } if let Some(shown) = &r.shown { diff --git a/desktop/src-tauri/src/bench/tests/channels.rs b/desktop/src-tauri/src/bench/tests/channels.rs index bdc7373a4ea..ef280cafd16 100644 --- a/desktop/src-tauri/src/bench/tests/channels.rs +++ b/desktop/src-tauri/src/bench/tests/channels.rs @@ -5,6 +5,7 @@ use super::super::roles::AgentAccess; use super::answers::{posted, reply, tap, ITEM}; +use super::replaced::last_edit; use super::*; pub(super) const X: &str = "105f15cc-1d5a-431a-9826-9402fba1a12b"; @@ -637,6 +638,41 @@ async fn closed_card_keeps_its_own_thread_root() { assert_eq!(parent(&posted(&b, &new.event_id)), Some(r2.as_str())); } +/// A newer card nobody touched, withdrawn: with only its seeds and closing edit +/// on it, a reply naming only the root still reaches the one open card there. +#[tokio::test] +async fn a_retired_card_nobody_touched_ends_no_older_cards_window() { + let mut b = seeded_bench(Gate::Open, &[]).await; + bind(&mut b); + let list = admins(&b, &b.relay.keys.clone(), &[]); + reply(&b, &[&list]); + let root = "ab".repeat(32); + b.drop_file("reset-worker", "welcome", &ask(&[("replyTo", json!(root))])); + b.settle(6).await; + let second = |validity: &str| { + ask(&[ + ("replyTo", json!(root)), + ("title", json!("Second")), + ("validity", json!(validity)), + ]) + }; + b.drop_file("reset-worker", "second", &second("current")); + b.settle(6).await; + let newer = b.card("reset-worker/second").event_id; + let post = posted(&b, &newer); + reply(&b, &[&list, &post]); + b.drop_file("reset-worker", "second", &second("superseded")); + b.tick().await.unwrap(); + let closing = serde_json::from_value(last_edit(&b, &newer)).unwrap(); + reply(&b, &[&list, &post, &closing]); + b.settle(3).await; + assert!(b.s.kept_at.is_empty(), "{:?}", b.s.kept_at); + let said = thread_reply(&b.ctx.owner, &root, "1", b.s.last_created_at + 5, &[]); + reply(&b, &[&list, &said]); + b.settle(3).await; + assert_eq!(b.s.items[ASK].answer.as_ref().map(|a| a.option), Some(0)); +} + #[tokio::test] async fn unreadable_roles_hold_scoped_drops_and_every_card() { let (mut b, _p, _list) = bound_bench().await; diff --git a/desktop/src-tauri/src/bench/tests/replaced.rs b/desktop/src-tauri/src/bench/tests/replaced.rs index 71688ecc96e..1a718e24202 100644 --- a/desktop/src-tauri/src/bench/tests/replaced.rs +++ b/desktop/src-tauri/src/bench/tests/replaced.rs @@ -22,7 +22,7 @@ fn redrop(b: &Bench, title: &str) { } /// The post the Bench last edited `card` to. -fn last_edit(b: &Bench, card: &str) -> Value { +pub(super) fn last_edit(b: &Bench, card: &str) -> Value { b.posts() .into_iter() .rev() From c70a81779c877f9da8d3f07eacff4009df749d69 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 9 Nov 2026 14:57:51 -0600 Subject: [PATCH 04/10] fix(bench): lost state never adopts a post edited closed Retired posts now stay on the relay with their bench:card marker. With state lost and the same Drop asked again, publish (c) adopted one as the live card: a seedless post saying a tap doesn't count, whose tap would then be read as the answer. An orphan edited closed now goes to kept like any orphan with something on it, and a new card goes up. One lookup per orphan, after lost state only. Claude-Account: russelledeming@gmail.com Co-Authored-By: Claude Opus 5.5 --- desktop/src-tauri/src/bench/publish.rs | 9 +++-- desktop/src-tauri/src/bench/tests/replaced.rs | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/bench/publish.rs b/desktop/src-tauri/src/bench/publish.rs index 192e44a455a..97bf9411b0f 100644 --- a/desktop/src-tauri/src/bench/publish.rs +++ b/desktop/src-tauri/src/bench/publish.rs @@ -702,19 +702,22 @@ pub(super) async fn publish( forget_card(ctx, s, &id); } - // (c) Post missing cards, adopting a recovered orphan when its hash matches. + // (c) Post missing cards, adopting a recovered orphan when its hash matches + // and it was not edited closed: that post says a tap there doesn't count. for id in &wanted { if s.items[id].card.is_some() { continue; } let ch = card_channel(s, &s.items[id]); if let Some(orphan) = s.orphan_cards.get(id).cloned() { - if orphan.hash == item::card_hash(&s.items[id]) { + let on = on_post(ctx, s, &ch, &orphan.event_id, now).await?; + let closed = on.iter().any(super::replaced::closes); + if orphan.hash == item::card_hash(&s.items[id]) && !closed { s.items.get_mut(id).expect("wanted id").card = s.orphan_cards.remove(id); continue; } // An answered, replied or receipt card stays on the relay for good. - if !on_post(ctx, s, &ch, &orphan.event_id, now).await?.is_empty() { + if !on.is_empty() { s.kept.push(orphan.event_id.clone()); } else { if !spend(budget, 1) { diff --git a/desktop/src-tauri/src/bench/tests/replaced.rs b/desktop/src-tauri/src/bench/tests/replaced.rs index 1a718e24202..d1cdffff404 100644 --- a/desktop/src-tauri/src/bench/tests/replaced.rs +++ b/desktop/src-tauri/src/bench/tests/replaced.rs @@ -409,6 +409,39 @@ async fn an_old_post_never_brings_a_repost() { assert_eq!(b.posts().len(), before, "no repost, no delete"); } +/// State lost, then the same Drop again: a post edited closed says a tap +/// doesn't count, so a new card goes up. One with its Recorded edit is the card. +#[tokio::test] +async fn lost_state_never_adopts_a_post_edited_closed() { + for closed in [true, false] { + let mut b = seeded_bench(Gate::Open, &[]).await; + let first = b.card(ITEM).event_id; + let post = posted(&b, &first); + if closed { + reply(&b, &[&post]); + let gone = drop_json(&[("validity", json!("superseded"))]); + b.drop_file("orchestrator", "relay-choice", &gone); + } else { + let owner_tap = tap(&b.ctx.owner, &first, OPTION_EMOJI[0], b.now); + reply(&b, &[&post, &owner_tap]); + } + b.settle(4).await; + let edit = last_edit(&b, &first); + assert_eq!(content(&edit).starts_with(NOT_ASKED), closed); + reply(&b, &[&post, &serde_json::from_value(edit).unwrap()]); + b.s.items.clear(); + b.s.kept.clear(); + b.s.replaced.clear(); + b.s.needs_rebuild = true; + b.settle(1).await; + assert!(b.s.orphan_cards.contains_key(ITEM)); + b.drop_file("orchestrator", "relay-choice", &drop_json(&[])); + b.settle(3).await; + assert_eq!(b.card(ITEM).event_id == first, !closed); + assert_eq!(b.s.kept.contains(&first), closed); + } +} + #[tokio::test] async fn a_tap_on_an_old_post_gets_one_reply_and_counts_for_nothing() { let (mut b, old, answer) = answered_then_replaced().await; From 8fb724db5522e070f89cce92a438a8b9ca7949fb Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 9 Nov 2026 13:52:20 -0600 Subject: [PATCH 05/10] fix(bench): a receipt says a tap doesn't count and loses its seeds Close-out receipts are the largest group of dead pills: 92 on 4 Nov. A close-out now makes its card an old post: first line, Closed note, seeds off, and for 24 h one reply to a tap. It stays item.card, so the kit's findCard is unaffected, and its edit no longer fails a tick. Receipts already in state catch up one at a time behind live work, unwatched, in about 4 minutes. Claude-Account: russelledeming@gmail.com Co-Authored-By: Claude Opus 5.5 --- desktop/src-tauri/src/bench/mod.rs | 2 + desktop/src-tauri/src/bench/publish.rs | 29 ++- desktop/src-tauri/src/bench/replaced.rs | 92 ++++++++-- desktop/src-tauri/src/bench/tests/receipts.rs | 53 +++--- desktop/src-tauri/src/bench/tests/replaced.rs | 167 +++++++++++++++++- 5 files changed, 283 insertions(+), 60 deletions(-) diff --git a/desktop/src-tauri/src/bench/mod.rs b/desktop/src-tauri/src/bench/mod.rs index 322e580993d..4f709f6d3bb 100644 --- a/desktop/src-tauri/src/bench/mod.rs +++ b/desktop/src-tauri/src/bench/mod.rs @@ -151,6 +151,8 @@ pub(crate) struct BenchState { /// Old posts owed an edit or a reply or watched for a tap; since when their read fails. replaced: Vec, replaced_unread: Option, + /// Receipts still to become old posts (`replaced::backfill`); None: unread. + receipts_todo: Option>, /// Live cards whose owner's page was cut this tick, or every item while /// roles.json does not read: their answers are unread, so nothing /// expires, restamps, routes, retires or replaces them. diff --git a/desktop/src-tauri/src/bench/publish.rs b/desktop/src-tauri/src/bench/publish.rs index 97bf9411b0f..58449590f60 100644 --- a/desktop/src-tauri/src/bench/publish.rs +++ b/desktop/src-tauri/src/bench/publish.rs @@ -370,10 +370,10 @@ fn bad_thread(e: &str) -> bool { } /// Cards, seeds, then the board, at most `budget` posts. A card is posted -/// once per Drop and card format; it is edited to record an answer, once closed -/// out to become a receipt and once stale to become an old post (`replaced`), so -/// only a recovered orphan nobody touched is ever deleted. Every post made once -/// a card's own id is known links its thread. +/// once per Drop and card format; it is edited to record an answer and, once +/// stale or closed out, to become an old post (`replaced`), so only a recovered +/// orphan nobody touched is ever deleted. Every post made once a card's own id +/// is known links its thread. /// An owner's notice that fails to go again lands in `failed`, for /// lastError, and the rest goes on. #[allow(clippy::too_many_arguments)] @@ -471,8 +471,9 @@ pub(super) async fn publish( } } - // (a2b) A close-out keeps the card: edited to the receipt, with the - // answer and this drop as its Outcome. A later close-out edits it again. + // (a2b) A close-out keeps the card, owed its edit to the receipt + // (`replaced`), with the answer and this drop as its Outcome. A later + // close-out edits it again. for id in s.items.keys().cloned().collect::>() { let item = &s.items[&id]; let (Some(_), Some(card)) = (&item.request, &item.card) else { @@ -482,19 +483,9 @@ pub(super) async fn publish( if card.hash == hash { continue; } - if !spend(budget, 1) { - return Ok(()); - } - let (event_id, ch) = (card.event_id.clone(), card_channel(s, item)); - let text = item::render_card(item, hhmm) + &item::thread_line(&ch, &event_id); - match edit(ctx, s, &ch, &event_id, &text, now).await { - Ok(_) => { - let i = s.items.get_mut(&id).expect("receipt id"); - i.card.as_mut().expect("receipt card").hash = hash; - } - Err(e) if e.contains("not found") => forget_card(ctx, s, &id), - Err(e) => return Err(e), - } + super::replaced::receipt(s, &id, now); + let i = s.items.get_mut(&id).expect("receipt id"); + i.card.as_mut().expect("receipt card").hash = hash; } // (a3) Tell the writer's route target of a settled answer change, and of diff --git a/desktop/src-tauri/src/bench/replaced.rs b/desktop/src-tauri/src/bench/replaced.rs index 6bce3bf4fc2..c5b9dc4ad69 100644 --- a/desktop/src-tauri/src/bench/replaced.rs +++ b/desktop/src-tauri/src/bench/replaced.rs @@ -1,11 +1,11 @@ -//! Old posts: cards a newer card replaced, or that stopped being asked, left -//! on the relay. Only a live card is read (`poll_answers`), yet the old post -//! stays in the owner's feed with its pills, and Block's phone shows nothing -//! but text and keycaps. So it says on its first line that a tap there doesn't -//! count and loses its seeds, and a tap on it all the same gets one reply in -//! its thread. That reply is all such a tap ever does: a tap is tied to the -//! card that was shown, so it never answers, never reaches the outbox and -//! never moves to the new card. +//! Old posts: cards a newer card replaced, that stopped being asked or that +//! closed out as receipts, left on the relay. Only a live card is read +//! (`poll_answers`), yet the old post stays in the owner's feed with its pills, +//! and Block's phone shows nothing but text and keycaps. So it says on its first +//! line that a tap there doesn't count and loses its seeds, and a tap on it all +//! the same gets one reply in its thread. That reply is all such a tap ever +//! does: a tap is tied to the card that was shown, so it never answers, never +//! reaches the outbox and never moves to the new card. //! //! None is deleted. A phone that lost its socket across the delete keeps //! showing the post, the relay refuses a tap on it and the phone's code ignores @@ -18,8 +18,8 @@ use std::collections::BTreeSet; use serde::{Deserialize, Serialize}; -use super::channels::{card_channel, ready}; -use super::publish::{edit, on_post, post_message, thread_of}; +use super::channels::{card_channel, channel_ready, ready}; +use super::publish::{edit, forget_card, on_post, post_message, thread_of}; use super::{ item, post_as_writer, query, query_writer, spend, BenchState, Ctx, Posted, RECOVER_LIMIT, }; @@ -117,6 +117,17 @@ fn add(s: &mut BenchState, r: Replaced) { s.replaced.push(r); } +/// The post of `old`, showing it, with the answer it was left with no new tap. +fn showing(s: &BenchState, old: &item::Item, card: &Posted, now: u64) -> Replaced { + let asked = old.request.as_deref().unwrap_or(old); + let ch = card_channel(s, old); + Replaced { + shown: Some(Box::new(old.clone())), + seen: asked.answer.iter().map(|a| a.reaction_id.clone()).collect(), + ..Replaced::new(&old.id, &ch, card, asked.reply_to.as_deref(), now) + } +} + /// A new Drop left the card of the item it replaces behind (`carry_card`): it /// has no card, the item still in state does. Edited from what it shows, its /// Recorded line or receipt included. @@ -125,16 +136,25 @@ pub(super) fn left_behind(s: &mut BenchState, new: &item::Item, now: u64) { return; }; let Some(card) = &old.card else { return }; - let asked = old.request.as_deref().unwrap_or(old); - let ch = card_channel(s, old); - let r = Replaced { - shown: Some(Box::new(old.clone())), - seen: asked.answer.iter().map(|a| a.reaction_id.clone()).collect(), - ..Replaced::new(&new.id, &ch, card, asked.reply_to.as_deref(), now) - }; + let r = showing(s, old, card, now); add(s, r); } +/// A close-out (publish (a2b)): its card stays the item's, and the post becomes +/// the receipt. A later one, while the post is still here, only changes what it +/// shows. +pub(super) fn receipt(s: &mut BenchState, id: &str, now: u64) { + let Some((i, card)) = s.items.get(id).and_then(|i| Some((i, i.card.as_ref()?))) else { + return; + }; + let r = showing(s, i, card, now); + let same = |o: &&mut Replaced| o.card.event_id == r.card.event_id; + match s.replaced.iter_mut().find(same) { + Some(o) => o.shown = r.shown, + None => add(s, r), + } +} + /// A stale card publish (b) just retired: its item holds the newer Drop, so it /// is edited from its own text as posted. pub(super) fn retired(s: &mut BenchState, id: &str, ch: &str, card: &Posted, now: u64) { @@ -145,6 +165,37 @@ pub(super) fn retired(s: &mut BenchState, id: &str, ch: &str, card: &Posted, now add(s, r); } +/// Receipts from before a close-out made an old post still show their pills and +/// no first line. One at a time, so a relay refusing them holds one, not all +/// (`alarm`), and live work goes first: 92 at 4 Nov 2026 take about 4 minutes. +/// Unwatched: most closed out days ago, past any window. +// ponytail: the 8 cards kept before old posts keep their pills, as state holds +// only their ids; read each one's newest edit from the relay if he taps one. +fn backfill(s: &mut BenchState, now: u64) { + if s.receipts_todo.is_none() { + let receipts = s + .items + .values() + .filter(|i| i.request.is_some() && i.card.is_some()); + s.receipts_todo = Some(receipts.map(|i| i.id.clone()).collect()); + } + if s.replaced.iter().any(|r| !r.idle()) { + return; + } + let todo = s.receipts_todo.as_deref().unwrap_or_default(); + let waits = |id: &String| s.items.get(id).is_some_and(|i| !channel_ready(s, i)); + let Some(at) = todo.iter().position(|id| !waits(id)) else { + return; + }; + let id = s.receipts_todo.as_mut().expect("just found").remove(at); + let Some(i) = s.items.get(&id).filter(|i| i.request.is_some()) else { + return; + }; + let Some(card) = &i.card else { return }; + let r = showing(s, i, card, now); + s.replaced.push(Replaced { watch: false, ..r }); +} + /// Whether `e` is an edit that made a post an old post. pub(super) fn closes(e: &nostr::Event) -> bool { let first = e.content.lines().next().unwrap_or_default(); @@ -427,6 +478,12 @@ pub(super) async fn tick( let r = &mut s.replaced[i]; (r.shown, r.tapped, r.seeds_off, r.watch) = (None, None, true, false); r.retry_at = 0; + // A receipt's card is still its item's, deleted by hand. + let (id, gone) = (r.item.clone(), r.card.event_id.clone()); + let card = s.items.get(&id).and_then(|i| i.card.as_ref()); + if card.is_some_and(|c| c.event_id == gone) { + forget_card(ctx, s, &id); + } } Err(e) => { eprintln!("bench: old card {}: {e}", s.replaced[i].card.event_id); @@ -438,4 +495,5 @@ pub(super) async fn tick( // try above, and one that keeps failing must not retry for good. s.replaced .retain(|r| now < r.at + WATCH_SECS && !(r.idle() && !r.watch)); + backfill(s, now); } diff --git a/desktop/src-tauri/src/bench/tests/receipts.rs b/desktop/src-tauri/src/bench/tests/receipts.rs index 556e7c8a66e..b6b6638f8dd 100644 --- a/desktop/src-tauri/src/bench/tests/receipts.rs +++ b/desktop/src-tauri/src/bench/tests/receipts.rs @@ -1,6 +1,6 @@ //! Receipts: a close-out keeps the card and edits it to add the answer and -//! the outcome. Child of tests.rs so the fake relay and Bench harness are in -//! scope. +//! the outcome, as an old post (replaced.rs). Child of tests.rs so the fake +//! relay and Bench harness are in scope. use super::answers::{last_query, reply, tap, ITEM}; use super::*; @@ -43,51 +43,65 @@ async fn close_out_edits_the_card_into_a_receipt() { let before = b.posts().len(); close_out(&b, "Moved the relay"); b.tick().await.unwrap(); - // The card's edit, then the board's: nothing is deleted or reposted. + // The board's edit, then the card's: nothing is deleted or reposted. let posts = b.posts()[before..].to_vec(); assert_eq!(b.kinds()[before..], [40003, 40003]); - assert_eq!(tag_value(&posts[0].0, "e").unwrap(), card.event_id); - assert_eq!(tag_value(&posts[0].0, "h").unwrap(), b.channel()); + assert_eq!(tag_value(&posts[1].0, "e").unwrap(), card.event_id); + assert_eq!(tag_value(&posts[1].0, "h").unwrap(), b.channel()); let item = &b.s.items[ITEM]; - let receipt = content(&posts[0].0); + let receipt = content(&posts[1].0); let link = format!( "buzz://message?channel={}&id={}", b.channel(), card.event_id ); + // The time the close-out came in, not the tick's. + let came_in = chrono::DateTime::parse_from_rfc3339(NOW).unwrap(); + let came_in = local_hhmm(came_in.timestamp() as u64); // The edit knows the card's id, so the receipt links its thread. assert_eq!( receipt, - render_card(item, "14:05") + &format!("\nOpen thread: {link}") + format!( + "No longer asked. A tap here doesn't count.\n{}\n\nClosed: a tap or reply here is \ + no longer read. If this is still asked, answer on the newer card.\n\ + Open thread: {link}", + render_card(item, &came_in) + ) ); let request = item.request.as_ref().unwrap(); let at = &request.answer.as_ref().unwrap().hhmm; assert!( - receipt.starts_with("馃敶 路 Dreamforge 路 orchestrator\n**Relay choice**\n"), + receipt.starts_with( + "No longer asked. A tap here doesn't count.\n\ + 馃敶 路 Dreamforge 路 orchestrator\n**Relay choice**\n" + ), "{receipt}" ); assert!( - receipt.ends_with(&format!( - "1. Stay\n2. Move\n\nAnswered: 2. Move 路 {at}\n\nOutcome 路 14:05\n\ + receipt.contains(&format!( + "1. Stay\n2. Move\n\nAnswered: 2. Move 路 {at}\n\nOutcome 路 {came_in}\n\ **Moved the relay**\n\nMoved the community to the new relay.\n\n\ - PR: https://github.com/x/y/pull/1\nOpen thread: {link}" + PR: https://github.com/x/y/pull/1\n" )), "{receipt}" ); assert_eq!(item.card.as_ref().unwrap().event_id, card.event_id); assert_eq!(item.card.as_ref().unwrap().hash, card_hash(item)); // The board links the decision to its receipt. - let board = content(&posts[1].0); + let board = content(&posts[0].0); assert!( board.contains(&format!( "- 馃Л Moved the relay (dreamforge) (claude) 路 {link}" )), "{board}" ); - // The poll reads the open card only, and nothing more is posted. + // The poll reads the open card, the old posts' read the receipt, and + // nothing more is posted. let before = b.posts().len(); b.settle(2).await; - assert_eq!(last_query(&b)[0]["#e"], json!([other])); + let queries = b.relay.queries.lock().unwrap().clone(); + assert_eq!(queries[queries.len() - 2][0]["#e"], json!([other])); + assert_eq!(last_query(&b)[0]["#e"], json!([card.event_id])); assert_eq!(b.posts().len(), before); // A relaunch keeps the receipt: no delete, no repost. let relay: Vec = b @@ -113,13 +127,12 @@ async fn unanswered_close_out_is_kept_and_a_second_one_re_edits() { b.tick().await.unwrap(); let posts = b.posts()[before..].to_vec(); assert_eq!(b.kinds()[before..], [40003, 40003]); - let receipt = content(&posts[0].0); + let receipt = content(&posts[1].0); assert!( - receipt.contains( - "\nAnswered: no answer recorded\n\nOutcome 路 14:05\n**Not needed after all**" - ), + receipt.contains("\nAnswered: no answer recorded\n\nOutcome 路 "), "{receipt}" ); + assert!(receipt.contains("\n**Not needed after all**"), "{receipt}"); // Later ticks neither retire nor re-edit it. let before = b.posts().len(); b.settle(3).await; @@ -129,8 +142,8 @@ async fn unanswered_close_out_is_kept_and_a_second_one_re_edits() { b.tick().await.unwrap(); let posts = b.posts()[before..].to_vec(); assert_eq!(b.kinds()[before..], [40003, 40003]); - assert_eq!(tag_value(&posts[0].0, "e").unwrap(), card); - let receipt = content(&posts[0].0); + assert_eq!(tag_value(&posts[1].0, "e").unwrap(), card); + let receipt = content(&posts[1].0); assert!( receipt.contains("**Relay choice**") && receipt.contains("**Moved after all**"), "{receipt}" diff --git a/desktop/src-tauri/src/bench/tests/replaced.rs b/desktop/src-tauri/src/bench/tests/replaced.rs index d1cdffff404..d7ceadb2f35 100644 --- a/desktop/src-tauri/src/bench/tests/replaced.rs +++ b/desktop/src-tauri/src/bench/tests/replaced.rs @@ -198,8 +198,8 @@ async fn a_cards_header_stays_as_posted_through_a_rename() { close_out(&b, "Moved the relay"); b.tick().await.unwrap(); let receipt = last_edit(&b, &second.event_id); - let closed_head = "馃敶 路 Forge 路 orchestrator\n"; - assert!(content(&receipt).starts_with(closed_head), "{receipt}"); + let closed_head = format!("{NOT_ASKED}\n馃敶 路 Forge 路 orchestrator\n"); + assert!(content(&receipt).starts_with(&closed_head), "{receipt}"); assert!(content(&receipt).contains("Moved the relay"), "{receipt}"); } @@ -648,10 +648,11 @@ async fn a_recorded_answer_from_a_clock_ahead_is_no_new_tap() { /// A tap after the item closed out: nothing is asked, so no live card is named. #[tokio::test] async fn a_tap_after_a_close_out_is_told_nothing_is_asked() { - let (mut b, old, answer) = answered_then_replaced().await; + let (mut b, _old, answer) = answered_then_replaced().await; + let receipt = b.card(ITEM).event_id; close_out(&b, "Moved the relay"); b.settle(2).await; - let stray = tap(&b.ctx.owner, &old.event_id, OPTION_EMOJI[0], b.now); + let stray = tap(&b.ctx.owner, &receipt, OPTION_EMOJI[0], b.now); reply(&b, &[&answer, &stray]); b.settle(2).await; let told = replies(&b); @@ -661,6 +662,164 @@ async fn a_tap_after_a_close_out_is_told_nothing_is_asked() { assert!(!text.contains("live card"), "{text}"); } +/// Close-out receipts are the largest group of dead pills: the first line, the +/// seeds off, the answer it closed with no new tap, its card still the item's +/// (the kit's `findCard` matches `item.card`). +#[tokio::test] +async fn a_receipt_says_so_and_loses_its_seeds() { + let mut b = seeded_bench(Gate::Open, &[]).await; + let card = b.card(ITEM); + let on_card = seeds(&b, &card.event_id); + assert_eq!(on_card.len(), 2); + // Its clock is ahead, so only `seen` keeps it from counting as new. + let answer = tap(&b.ctx.owner, &card.event_id, OPTION_EMOJI[1], b.now + 500); + let mut relay: Vec<&nostr::Event> = on_card.iter().collect(); + relay.push(&answer); + reply(&b, &relay); + b.tick().await.unwrap(); + close_out(&b, "Moved the relay"); + b.settle(4).await; + + let edited = last_edit(&b, &card.event_id); + let text = content(&edited); + assert!(text.starts_with(&format!("{NOT_ASKED}\n馃敶 路 ")), "{text}"); + assert!(text.contains("\nAnswered: 2. Move"), "{text}"); + assert!(text.contains("**Moved the relay**"), "{text}"); + let off = removed(&b); + for seed in &on_card { + assert!(off.contains(&seed.id.to_hex()), "{off:?}"); + } + assert!(!off.contains(&answer.id.to_hex()) && !off.contains(&card.event_id)); + assert!(replies(&b).is_empty(), "the answer it closed with"); + assert!(b.s.kept.is_empty()); + assert_eq!(b.card(ITEM).event_id, card.event_id); +} + +/// Its edit failing used to fail the tick, and lastError stops the kit. +#[tokio::test] +async fn a_receipt_that_will_not_update_fails_no_tick_and_one_gone_is_forgotten() { + let mut b = seeded_bench(Gate::Open, &[]).await; + let card = b.card(ITEM).event_id; + close_out(&b, "Moved the relay"); + *b.relay.post_down.lock().unwrap() = Some(card.clone()); + b.tick().await.unwrap(); + assert!(b.s.replaced[0].shown.is_some() && b.s.replaced[0].retry_at > 0); + // A second close-out while it is owed: one post, one edit, the last words. + close_out(&b, "Moved after all"); + b.tick().await.unwrap(); + assert_eq!(b.s.replaced.len(), 1); + *b.relay.post_down.lock().unwrap() = None; + b.now = b.s.replaced[0].retry_at; + b.settle(2).await; + let text = last_edit(&b, &card); + assert!(content(&text).contains("**Moved after all**"), "{text}"); + + // A later close-out on a receipt deleted by hand: the item lets it go. + b.relay.gone.lock().unwrap().insert(card); + close_out(&b, "Moved again"); + b.settle(3).await; + assert!(b.s.items[ITEM].card.is_none()); +} + +/// Receipts from before this build: one at a time and unwatched, each with its +/// first line and without its seeds; then never again. +#[tokio::test] +async fn old_receipts_are_edited_one_at_a_time_then_never_again() { + let mut b = seeded_bench(Gate::Open, &[]).await; + b.drop_file( + "orchestrator", + "other", + &drop_json(&[("title", json!("Other"))]), + ); + b.settle(4).await; + let (a, z) = (b.card(ITEM).event_id, b.card("orchestrator/other").event_id); + let on_card = [seeds(&b, &a), seeds(&b, &z)].concat(); + let done = drop_json(&[ + ("kind", json!("status")), + ("state", json!("done")), + ("severity", Value::Null), + ("options", Value::Null), + ]); + b.drop_file("orchestrator", "relay-choice", &done); + b.drop_file("orchestrator", "other", &done); + b.settle(6).await; + // As a build before this left them: nothing owed, not listed. + b.s.replaced.clear(); + b.s.receipts_todo = None; + reply(&b, &on_card.iter().collect::>()); + let before = b.posts().len(); + b.tick().await.unwrap(); + assert_eq!( + b.posts().len(), + before, + "listed this tick, edited from the next" + ); + assert_eq!(b.s.receipts_todo.as_ref().unwrap().len(), 1); + assert!(b.s.replaced.len() == 1 && !b.s.replaced[0].watch); + b.settle(10).await; + assert_eq!(b.s.receipts_todo.as_deref(), Some(&[][..])); + assert!(b.s.replaced.is_empty(), "let go once done"); + for card in [&a, &z] { + let text = last_edit(&b, card); + assert!(content(&text).starts_with(NOT_ASKED), "{text}"); + } + let off = removed(&b); + for seed in &on_card { + let n = off.iter().filter(|id| **id == seed.id.to_hex()).count(); + assert_eq!(n, 1, "{off:?}"); + } + let before = b.posts().len(); + b.settle(5).await; + assert_eq!(b.posts().len(), before, "never again"); +} + +/// One that can't be told yet stays listed; one asked again since has a live card, +/// which is never edited as an old post. +#[tokio::test] +async fn an_old_receipt_waits_for_its_channel_and_one_asked_again_is_skipped() { + let mut b = seeded_bench(Gate::Open, &[]).await; + close_out(&b, "Moved the relay"); + b.settle(3).await; + b.s.replaced.clear(); + b.s.receipts_todo = Some(vec![ITEM.to_string()]); + let in_x = |b: &mut Bench, ch: Option| { + b.s.items + .get_mut(ITEM) + .unwrap() + .request + .as_mut() + .unwrap() + .channel = ch; + }; + in_x(&mut b, Some(X.to_string())); + b.settle(2).await; + assert!(b.s.replaced.is_empty()); + assert_eq!(b.s.receipts_todo.as_ref().unwrap().len(), 1); + in_x(&mut b, None); + redrop(&b, "Relay choice, asked again"); + b.settle(6).await; + assert_eq!(b.s.receipts_todo.as_deref(), Some(&[][..])); + let live = b.card(ITEM).event_id; + let on_live = |e: &Value| e["kind"] == 40003 && tag_value(e, "e").as_ref() == Some(&live); + assert!(!b.posts().iter().any(|(e, _)| on_live(e))); +} + +/// A relay refusing them holds one on the board, not all of them in state. +#[tokio::test] +async fn an_old_receipt_that_will_not_update_holds_the_rest() { + let mut b = seeded_bench(Gate::Open, &[]).await; + let card = b.card(ITEM).event_id; + close_out(&b, "Moved the relay"); + b.settle(3).await; + b.s.replaced.clear(); + b.s.receipts_todo = Some(vec![ITEM.to_string(), ITEM.to_string()]); + *b.relay.post_down.lock().unwrap() = Some(card); + b.settle(4).await; + assert_eq!(b.s.replaced.len(), 1); + assert_eq!(b.s.receipts_todo.as_ref().unwrap().len(), 1); + assert!(board(&b).contains("Would not update: 1."), "{}", board(&b)); +} + /// A channel that isn't ready: nothing there is read, edited or counted against /// the cap, and it all resumes when the channel is ready. #[tokio::test] From a7d091c5ac88a72ae65011a635f647eeabb3a0b6 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 9 Nov 2026 14:57:51 -0600 Subject: [PATCH 06/10] test(bench): pin the held guard on retiring a stale-format card Nothing failed with `held` taken out of publish (b)'s guard: a re-worded drop waits in the inbox before it gets there, so only a card whose format moved reaches it. Without the guard that card is retired for good with a tap on it unread. Claude-Account: russelledeming@gmail.com Co-Authored-By: Claude Opus 5.5 --- .../src-tauri/src/bench/tests/owner_word.rs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/desktop/src-tauri/src/bench/tests/owner_word.rs b/desktop/src-tauri/src/bench/tests/owner_word.rs index dd62e9b714d..e31aaf8b717 100644 --- a/desktop/src-tauri/src/bench/tests/owner_word.rs +++ b/desktop/src-tauri/src/bench/tests/owner_word.rs @@ -199,6 +199,40 @@ async fn a_cut_owner_page_retires_nothing() { assert!(!deleted(&b)); } +/// `held` is all that keeps a card from retiring when only its card format +/// moved: nothing asked again waits in the inbox, and the tap is still unread. +#[tokio::test] +async fn a_cut_owner_page_leaves_a_stale_format_card_up() { + let mut b = seeded_bench(Gate::Open, &[]).await; + bind(&mut b); + let list = admins(&b, &b.relay.keys.clone(), &[]); + reply(&b, &[&list]); + let root = "ab".repeat(32); + b.drop_file("reset-worker", "welcome", &ask(&[("replyTo", json!(root))])); + b.settle(6).await; + let i = b.s.items.get_mut(ASK).unwrap(); + i.card.as_mut().unwrap().hash = format!("{}.3", i.hash); + let card = b.card(ASK); + let two = tap(&b.ctx.owner, &card.event_id, OPTION_EMOJI[1], b.now); + let chat = owners_chat(&b, &root, card.created_at); + let mut evs = vec![&list, &two]; + evs.extend(&chat); + reply(&b, &evs); + let before = b.posts().len(); + for _ in 0..3 { + let _ = b.tick().await; + } + assert_eq!(b.s.held, [ASK]); + assert_eq!(b.card(ASK), card); + assert!(b.s.replaced.is_empty()); + let edited = |e: &Value| e["kind"] == 40003 && tag_value(e, "e") == Some(card.event_id.clone()); + assert!(!b.posts()[before..].iter().any(|(e, _)| edited(e))); + // Read in full, the tap counts on the card it was made on. + reply(&b, &[&list, &two]); + b.tick().await.unwrap(); + assert_eq!(b.s.items[ASK].answer.as_ref().map(|a| a.option), Some(1)); +} + #[tokio::test] async fn a_re_asked_card_still_ends_an_older_cards_window() { let mut b = seeded_bench(Gate::Open, &[]).await; From 7f3ceb55f3f75ca69b8ace8c3bc6eae6bb501e16 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 9 Nov 2026 14:46:58 -0600 Subject: [PATCH 07/10] test(bench): pin a threaded receipt's reply to its thread root The real relay refuses a reply whose root doesn't match the thread, but the fake one doesn't check, so every Bench test still passed with the root taken out of showing(). A tap on a receipt in a thread would have failed its one reply for the whole window, with CI green. Claude-Account: russelledeming@gmail.com Co-Authored-By: Claude Opus 5.5 --- desktop/src-tauri/src/bench/tests/replaced.rs | 160 +++++++++++++++++- 1 file changed, 159 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/bench/tests/replaced.rs b/desktop/src-tauri/src/bench/tests/replaced.rs index d7ceadb2f35..72f93386254 100644 --- a/desktop/src-tauri/src/bench/tests/replaced.rs +++ b/desktop/src-tauri/src/bench/tests/replaced.rs @@ -3,7 +3,7 @@ //! harness are in scope. use super::answers::{last_query, outbox, posted, reply, tap, ITEM}; -use super::channels::X; +use super::channels::{admins, ask, bind, ASK, X}; use super::receipts::close_out; use super::*; use crate::bench::channels::ChannelCheck; @@ -92,6 +92,18 @@ async fn old_tick(b: &mut Bench, mut budget: usize) -> usize { budget } +/// The scoped card ASK closes out. +fn done_in_x() -> String { + drop_json(&[ + ("project", json!("reset")), + ("kind", json!("status")), + ("state", json!("done")), + ("severity", Value::Null), + ("options", Value::Null), + ("title", json!("Done in X")), + ]) +} + /// An old post already edited closed. fn closed(s: &mut BenchState, id: &str, ch: &str, card: &Posted, root: Option<&str>, now: u64) { retired(s, id, ch, card, now); @@ -665,6 +677,152 @@ async fn a_tap_after_a_close_out_is_told_nothing_is_asked() { /// Close-out receipts are the largest group of dead pills: the first line, the /// seeds off, the answer it closed with no new tap, its card still the item's /// (the kit's `findCard` matches `item.card`). +/// The relay refuses a reply whose root is not its thread's, so a tap on a +/// receipt posted in a thread is told under that thread's root. +#[tokio::test] +async fn a_tap_on_a_receipt_in_a_thread_is_told_under_its_root() { + let mut b = seeded_bench(Gate::Open, &[]).await; + bind(&mut b); + let list = admins(&b, &b.relay.keys.clone(), &[]); + reply(&b, &[&list]); + let root = "ab".repeat(32); + b.drop_file("reset-worker", "welcome", &ask(&[("replyTo", json!(root))])); + b.settle(6).await; + let card = b.card(ASK).event_id; + b.drop_file("reset-worker", "welcome", &done_in_x()); + b.settle(3).await; + assert!(b.s.items[ASK].request.is_some()); + let stray = tap(&b.ctx.owner, &card, OPTION_EMOJI[0], b.now + 5); + reply(&b, &[&list, &stray]); + b.settle(3).await; + let told = replies(&b); + assert_eq!(told.len(), 1); + let named = |marker: &str| { + let tags = told[0]["tags"].as_array().unwrap(); + let tag = tags.iter().find(|t| t[0] == "e" && t[3] == marker).unwrap(); + tag[1].as_str().unwrap().to_string() + }; + assert_eq!(named("root"), root); + assert_eq!(named("reply"), card); +} + +/// Close-out receipts are the largest group of dead pills: the first line, the +/// seeds off, the answer it closed with no new tap, its card still the item's +/// (the kit's `findCard` matches `item.card`). +/// The relay refuses a reply whose root is not its thread's, so a tap on a +/// receipt posted in a thread is told under that thread's root. +#[tokio::test] +async fn a_tap_on_a_receipt_in_a_thread_is_told_under_its_root() { + let mut b = seeded_bench(Gate::Open, &[]).await; + bind(&mut b); + let list = admins(&b, &b.relay.keys.clone(), &[]); + reply(&b, &[&list]); + let root = "ab".repeat(32); + b.drop_file("reset-worker", "welcome", &ask(&[("replyTo", json!(root))])); + b.settle(6).await; + let card = b.card(ASK).event_id; + b.drop_file("reset-worker", "welcome", &done_in_x()); + b.settle(3).await; + assert!(b.s.items[ASK].request.is_some()); + let stray = tap(&b.ctx.owner, &card, OPTION_EMOJI[0], b.now + 5); + reply(&b, &[&list, &stray]); + b.settle(3).await; + let told = replies(&b); + assert_eq!(told.len(), 1); + let named = |marker: &str| { + let tags = told[0]["tags"].as_array().unwrap(); + let tag = tags.iter().find(|t| t[0] == "e" && t[3] == marker).unwrap(); + tag[1].as_str().unwrap().to_string() + }; + assert_eq!(named("root"), root); + assert_eq!(named("reply"), card); +} + +/// A close-out in a channel that stays refused for more than a day: its edit +/// waits with the channel, unowed, and goes out once the channel is ready. +#[tokio::test] +async fn a_close_out_in_a_channel_not_ready_keeps_its_edit() { + let (mut b, p, list) = bound_bench().await; + let card = b.card(ASK).event_id; + let bad = admins(&b, &b.relay.keys.clone(), &[&hex(&p.craig)]); + reply(&b, &[&bad]); + b.now += 601; + b.settle(2).await; + assert!(b.s.channels[X].refused.is_some()); + b.drop_file("reset-worker", "welcome", &done_in_x()); + let before = b.posts().len(); + b.settle(2).await; + assert!(b.s.items[ASK].request.is_some()); + b.now += 86_400; + b.settle(2).await; + let edited = |b: &Bench| { + let on_card = |e: &Value| e["kind"] == 40003 && tag_value(e, "e").as_deref() == Some(&card); + b.posts()[before..].iter().any(|(e, _)| on_card(e)) + }; + assert!(!edited(&b), "not while the channel is refused"); + reply(&b, &[&list]); + b.now += 61; + b.settle(4).await; + assert_eq!(b.s.channels[X].refused, None); + assert!(edited(&b), "the close-out's edit never went out"); + let receipt = last_edit(&b, &card); + assert!(content(&receipt).starts_with(NOT_ASKED), "{receipt}"); + assert!(content(&receipt).contains("Done in X"), "{receipt}"); +} + +/// A later close-out's edit, failing once near the end of the first one's +/// 24 h, still gets its retry. +#[tokio::test] +async fn a_later_close_out_gets_its_own_window() { + let mut b = seeded_bench(Gate::Open, &[]).await; + let card = b.card(ITEM).event_id; + close_out(&b, "First report"); + b.settle(3).await; + assert!(b.s.replaced[0].shown.is_none() && b.s.replaced[0].watch); + b.now = b.s.replaced[0].at + 86_400 - 6; + *b.relay.post_down.lock().unwrap() = Some(card.clone()); + close_out(&b, "Second report"); + let before = b.posts().len(); + b.tick().await.unwrap(); + assert!(b.s.replaced[0].retry_at > 0, "one failed try"); + *b.relay.post_down.lock().unwrap() = None; + b.now += 60; + b.settle(4).await; + assert_eq!(b.s.last_error, None); + // The fake relay logs a refused write too, so two is the retry. + let second = b.posts()[before..] + .iter() + .filter(|(e, _)| e["kind"] == 40003 && tag_value(e, "e").as_deref() == Some(&card)) + .filter(|(e, _)| content(e).contains("Second report")) + .count(); + assert_eq!(second, 2); +} + +/// A new card format is no close-out: receipts closed days ago are edited +/// again, unwatched, and leave the cap to posts a tap could still come to. +#[tokio::test] +async fn a_format_bump_watches_no_old_receipt() { + let mut b = seeded_bench(Gate::Open, &[]).await; + close_out(&b, "Moved the relay"); + b.settle(3).await; + let receipt = b.s.items[ITEM].clone(); + b.s.replaced.clear(); + b.s.receipts_todo = Some(Vec::new()); + for n in 0..=WATCH_MAX { + let mut i = receipt.clone(); + i.id = format!("orchestrator/r{n}"); + let c = i.card.as_mut().unwrap(); + c.event_id = format!("{n:064x}"); + c.hash = format!("{}.3", i.hash); + b.s.items.insert(i.id.clone(), i); + } + b.tick().await.unwrap(); + assert_eq!(b.s.replaced.len(), WATCH_MAX + 1); + assert!(b.s.replaced.iter().all(|r| !r.watch)); + b.tick().await.unwrap(); + assert!(!board(&b).contains("Watched for a tap"), "{}", board(&b)); +} + #[tokio::test] async fn a_receipt_says_so_and_loses_its_seeds() { let mut b = seeded_bench(Gate::Open, &[]).await; From 7fd662c9c06fe6f32612968d5d4c99e212d128ff Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 9 Nov 2026 14:46:58 -0600 Subject: [PATCH 08/10] fix(bench): a close-out waits for its channel before it is owed Publish (a2b) moved the card's hash on and owed the edit even in a channel that isn't ready. The old posts' tick skips those, so past 24 h the entry went with no try and no alarm, and the hash had already moved, so the receipt kept its live text for good. The hash now stays behind until the channel is ready, so the close-out is picked up then, however long that takes. Claude-Account: russelledeming@gmail.com Co-Authored-By: Claude Opus 5.5 --- desktop/src-tauri/src/bench/publish.rs | 4 ++- desktop/src-tauri/src/bench/tests/replaced.rs | 34 ++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/bench/publish.rs b/desktop/src-tauri/src/bench/publish.rs index 58449590f60..87409e8c79d 100644 --- a/desktop/src-tauri/src/bench/publish.rs +++ b/desktop/src-tauri/src/bench/publish.rs @@ -480,7 +480,9 @@ pub(super) async fn publish( continue; }; let hash = item::card_hash(item); - if card.hash == hash { + // A channel not ready takes no edit (`replaced`): the hash stays behind, + // so the close-out is picked up once it is ready. + if card.hash == hash || !channel_ready(s, item) { continue; } super::replaced::receipt(s, &id, now); diff --git a/desktop/src-tauri/src/bench/tests/replaced.rs b/desktop/src-tauri/src/bench/tests/replaced.rs index 72f93386254..dcebe104cb2 100644 --- a/desktop/src-tauri/src/bench/tests/replaced.rs +++ b/desktop/src-tauri/src/bench/tests/replaced.rs @@ -3,7 +3,7 @@ //! harness are in scope. use super::answers::{last_query, outbox, posted, reply, tap, ITEM}; -use super::channels::{admins, ask, bind, ASK, X}; +use super::channels::{admins, ask, bind, bound_bench, hex, ASK, X}; use super::receipts::close_out; use super::*; use crate::bench::channels::ChannelCheck; @@ -706,6 +706,38 @@ async fn a_tap_on_a_receipt_in_a_thread_is_told_under_its_root() { assert_eq!(named("reply"), card); } +/// A close-out in a channel that stays refused for more than a day: its edit +/// waits with the channel, unowed, and goes out once the channel is ready. +#[tokio::test] +async fn a_close_out_in_a_channel_not_ready_keeps_its_edit() { + let (mut b, p, list) = bound_bench().await; + let card = b.card(ASK).event_id; + let bad = admins(&b, &b.relay.keys.clone(), &[&hex(&p.craig)]); + reply(&b, &[&bad]); + b.now += 601; + b.settle(2).await; + assert!(b.s.channels[X].refused.is_some()); + b.drop_file("reset-worker", "welcome", &done_in_x()); + let before = b.posts().len(); + b.settle(2).await; + assert!(b.s.items[ASK].request.is_some()); + b.now += 86_400; + b.settle(2).await; + let edited = |b: &Bench| { + let on_card = |e: &Value| e["kind"] == 40003 && tag_value(e, "e").as_deref() == Some(&card); + b.posts()[before..].iter().any(|(e, _)| on_card(e)) + }; + assert!(!edited(&b), "not while the channel is refused"); + reply(&b, &[&list]); + b.now += 61; + b.settle(4).await; + assert_eq!(b.s.channels[X].refused, None); + assert!(edited(&b), "the close-out's edit never went out"); + let receipt = last_edit(&b, &card); + assert!(content(&receipt).starts_with(NOT_ASKED), "{receipt}"); + assert!(content(&receipt).contains("Done in X"), "{receipt}"); +} + /// Close-out receipts are the largest group of dead pills: the first line, the /// seeds off, the answer it closed with no new tap, its card still the item's /// (the kit's `findCard` matches `item.card`). From ce7677abe0bd30255b76f7fd226247a2fa1aa543 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 9 Nov 2026 14:46:58 -0600 Subject: [PATCH 09/10] fix(bench): a later close-out gets its own 24 hours A later close-out on a receipt still in `replaced` changed only what it shows and kept the first one's clock. An edit failing once near the end of those 24 h was let go before its retry, and the hash had moved on, so the phone kept the older report with nothing said after that. Claude-Account: russelledeming@gmail.com Co-Authored-By: Claude Opus 5.5 --- desktop/src-tauri/src/bench/replaced.rs | 6 ++-- desktop/src-tauri/src/bench/tests/replaced.rs | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/bench/replaced.rs b/desktop/src-tauri/src/bench/replaced.rs index c5b9dc4ad69..f17d4269307 100644 --- a/desktop/src-tauri/src/bench/replaced.rs +++ b/desktop/src-tauri/src/bench/replaced.rs @@ -141,8 +141,8 @@ pub(super) fn left_behind(s: &mut BenchState, new: &item::Item, now: u64) { } /// A close-out (publish (a2b)): its card stays the item's, and the post becomes -/// the receipt. A later one, while the post is still here, only changes what it -/// shows. +/// the receipt. A later one, while the post is still here, changes what it shows +/// and starts its 24 h again. pub(super) fn receipt(s: &mut BenchState, id: &str, now: u64) { let Some((i, card)) = s.items.get(id).and_then(|i| Some((i, i.card.as_ref()?))) else { return; @@ -150,7 +150,7 @@ pub(super) fn receipt(s: &mut BenchState, id: &str, now: u64) { let r = showing(s, i, card, now); let same = |o: &&mut Replaced| o.card.event_id == r.card.event_id; match s.replaced.iter_mut().find(same) { - Some(o) => o.shown = r.shown, + Some(o) => (o.shown, o.at) = (r.shown, r.at), None => add(s, r), } } diff --git a/desktop/src-tauri/src/bench/tests/replaced.rs b/desktop/src-tauri/src/bench/tests/replaced.rs index dcebe104cb2..42f60212d95 100644 --- a/desktop/src-tauri/src/bench/tests/replaced.rs +++ b/desktop/src-tauri/src/bench/tests/replaced.rs @@ -738,6 +738,34 @@ async fn a_close_out_in_a_channel_not_ready_keeps_its_edit() { assert!(content(&receipt).contains("Done in X"), "{receipt}"); } +/// A later close-out's edit, failing once near the end of the first one's +/// 24 h, still gets its retry. +#[tokio::test] +async fn a_later_close_out_gets_its_own_window() { + let mut b = seeded_bench(Gate::Open, &[]).await; + let card = b.card(ITEM).event_id; + close_out(&b, "First report"); + b.settle(3).await; + assert!(b.s.replaced[0].shown.is_none() && b.s.replaced[0].watch); + b.now = b.s.replaced[0].at + 86_400 - 6; + *b.relay.post_down.lock().unwrap() = Some(card.clone()); + close_out(&b, "Second report"); + let before = b.posts().len(); + b.tick().await.unwrap(); + assert!(b.s.replaced[0].retry_at > 0, "one failed try"); + *b.relay.post_down.lock().unwrap() = None; + b.now += 60; + b.settle(4).await; + assert_eq!(b.s.last_error, None); + // The fake relay logs a refused write too, so two is the retry. + let second = b.posts()[before..] + .iter() + .filter(|(e, _)| e["kind"] == 40003 && tag_value(e, "e").as_deref() == Some(&card)) + .filter(|(e, _)| content(e).contains("Second report")) + .count(); + assert_eq!(second, 2); +} + /// Close-out receipts are the largest group of dead pills: the first line, the /// seeds off, the answer it closed with no new tap, its card still the item's /// (the kit's `findCard` matches `item.card`). From 39ed266b3c35d33a6ed222b0ad26ee6cd1ca43a5 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Mon, 9 Nov 2026 14:46:58 -0600 Subject: [PATCH 10/10] fix(bench): a card format bump watches no old receipt A format bump changes every receipt's hash, so publish (a2b) made each one a watched old post in one tick. That filled WATCH_MAX with posts closed days ago, put a false alarm on the board and pushed real old posts out of the read. Only a real close-out is watched now. When just the format or stamp moved the receipt is edited again unwatched, as the backfill does. Claude-Account: russelledeming@gmail.com Co-Authored-By: Claude Opus 5.5 --- desktop/src-tauri/src/bench/replaced.rs | 10 +- desktop/src-tauri/src/bench/tests/replaced.rs | 92 ------------------- 2 files changed, 7 insertions(+), 95 deletions(-) diff --git a/desktop/src-tauri/src/bench/replaced.rs b/desktop/src-tauri/src/bench/replaced.rs index f17d4269307..71653d50c73 100644 --- a/desktop/src-tauri/src/bench/replaced.rs +++ b/desktop/src-tauri/src/bench/replaced.rs @@ -21,7 +21,8 @@ use serde::{Deserialize, Serialize}; use super::channels::{card_channel, channel_ready, ready}; use super::publish::{edit, forget_card, on_post, post_message, thread_of}; use super::{ - item, post_as_writer, query, query_writer, spend, BenchState, Ctx, Posted, RECOVER_LIMIT, + item, post_as_writer, query, query_writer, same_drop, spend, BenchState, Ctx, Posted, + RECOVER_LIMIT, }; use crate::events; @@ -142,16 +143,19 @@ pub(super) fn left_behind(s: &mut BenchState, new: &item::Item, now: u64) { /// A close-out (publish (a2b)): its card stays the item's, and the post becomes /// the receipt. A later one, while the post is still here, changes what it shows -/// and starts its 24 h again. +/// and starts its 24 h again. A new card format or stamp is no close-out, so it +/// is edited again unwatched, as `backfill` does: most closed out days ago. pub(super) fn receipt(s: &mut BenchState, id: &str, now: u64) { let Some((i, card)) = s.items.get(id).and_then(|i| Some((i, i.card.as_ref()?))) else { return; }; let r = showing(s, i, card, now); + let closed_out = !same_drop(card, i); let same = |o: &&mut Replaced| o.card.event_id == r.card.event_id; match s.replaced.iter_mut().find(same) { Some(o) => (o.shown, o.at) = (r.shown, r.at), - None => add(s, r), + None if closed_out => add(s, r), + None => s.replaced.push(Replaced { watch: false, ..r }), } } diff --git a/desktop/src-tauri/src/bench/tests/replaced.rs b/desktop/src-tauri/src/bench/tests/replaced.rs index 42f60212d95..9aec14b3a9c 100644 --- a/desktop/src-tauri/src/bench/tests/replaced.rs +++ b/desktop/src-tauri/src/bench/tests/replaced.rs @@ -766,98 +766,6 @@ async fn a_later_close_out_gets_its_own_window() { assert_eq!(second, 2); } -/// Close-out receipts are the largest group of dead pills: the first line, the -/// seeds off, the answer it closed with no new tap, its card still the item's -/// (the kit's `findCard` matches `item.card`). -/// The relay refuses a reply whose root is not its thread's, so a tap on a -/// receipt posted in a thread is told under that thread's root. -#[tokio::test] -async fn a_tap_on_a_receipt_in_a_thread_is_told_under_its_root() { - let mut b = seeded_bench(Gate::Open, &[]).await; - bind(&mut b); - let list = admins(&b, &b.relay.keys.clone(), &[]); - reply(&b, &[&list]); - let root = "ab".repeat(32); - b.drop_file("reset-worker", "welcome", &ask(&[("replyTo", json!(root))])); - b.settle(6).await; - let card = b.card(ASK).event_id; - b.drop_file("reset-worker", "welcome", &done_in_x()); - b.settle(3).await; - assert!(b.s.items[ASK].request.is_some()); - let stray = tap(&b.ctx.owner, &card, OPTION_EMOJI[0], b.now + 5); - reply(&b, &[&list, &stray]); - b.settle(3).await; - let told = replies(&b); - assert_eq!(told.len(), 1); - let named = |marker: &str| { - let tags = told[0]["tags"].as_array().unwrap(); - let tag = tags.iter().find(|t| t[0] == "e" && t[3] == marker).unwrap(); - tag[1].as_str().unwrap().to_string() - }; - assert_eq!(named("root"), root); - assert_eq!(named("reply"), card); -} - -/// A close-out in a channel that stays refused for more than a day: its edit -/// waits with the channel, unowed, and goes out once the channel is ready. -#[tokio::test] -async fn a_close_out_in_a_channel_not_ready_keeps_its_edit() { - let (mut b, p, list) = bound_bench().await; - let card = b.card(ASK).event_id; - let bad = admins(&b, &b.relay.keys.clone(), &[&hex(&p.craig)]); - reply(&b, &[&bad]); - b.now += 601; - b.settle(2).await; - assert!(b.s.channels[X].refused.is_some()); - b.drop_file("reset-worker", "welcome", &done_in_x()); - let before = b.posts().len(); - b.settle(2).await; - assert!(b.s.items[ASK].request.is_some()); - b.now += 86_400; - b.settle(2).await; - let edited = |b: &Bench| { - let on_card = |e: &Value| e["kind"] == 40003 && tag_value(e, "e").as_deref() == Some(&card); - b.posts()[before..].iter().any(|(e, _)| on_card(e)) - }; - assert!(!edited(&b), "not while the channel is refused"); - reply(&b, &[&list]); - b.now += 61; - b.settle(4).await; - assert_eq!(b.s.channels[X].refused, None); - assert!(edited(&b), "the close-out's edit never went out"); - let receipt = last_edit(&b, &card); - assert!(content(&receipt).starts_with(NOT_ASKED), "{receipt}"); - assert!(content(&receipt).contains("Done in X"), "{receipt}"); -} - -/// A later close-out's edit, failing once near the end of the first one's -/// 24 h, still gets its retry. -#[tokio::test] -async fn a_later_close_out_gets_its_own_window() { - let mut b = seeded_bench(Gate::Open, &[]).await; - let card = b.card(ITEM).event_id; - close_out(&b, "First report"); - b.settle(3).await; - assert!(b.s.replaced[0].shown.is_none() && b.s.replaced[0].watch); - b.now = b.s.replaced[0].at + 86_400 - 6; - *b.relay.post_down.lock().unwrap() = Some(card.clone()); - close_out(&b, "Second report"); - let before = b.posts().len(); - b.tick().await.unwrap(); - assert!(b.s.replaced[0].retry_at > 0, "one failed try"); - *b.relay.post_down.lock().unwrap() = None; - b.now += 60; - b.settle(4).await; - assert_eq!(b.s.last_error, None); - // The fake relay logs a refused write too, so two is the retry. - let second = b.posts()[before..] - .iter() - .filter(|(e, _)| e["kind"] == 40003 && tag_value(e, "e").as_deref() == Some(&card)) - .filter(|(e, _)| content(e).contains("Second report")) - .count(); - assert_eq!(second, 2); -} - /// A new card format is no close-out: receipts closed days ago are edited /// again, unwatched, and leave the cap to posts a tap could still come to. #[tokio::test]