From a2b8700c8eaf4be117c254afec7d090c64f3713f Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:45:38 +0800 Subject: [PATCH] Notifications: no late summary when a cooldown ends A one-off event (a blocked tool call, a security rule match) pops once and then cools down for five minutes. When the cooldown ended, any repeats in that window were summed up in a second notification, which arrived minutes after the request or session it described had finished. Repeats now only update the in-app list (count, unread) and the delivered notification in place, quietly; once the cooldown is over, the next occurrence pops at once. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/notices/mod.rs | 98 +++++----------------------------- src-tauri/src/notices/tests.rs | 66 ++++++++++++----------- 2 files changed, 47 insertions(+), 117 deletions(-) diff --git a/src-tauri/src/notices/mod.rs b/src-tauri/src/notices/mod.rs index a9f3f3c..a94b8d6 100644 --- a/src-tauri/src/notices/mod.rs +++ b/src-tauri/src/notices/mod.rs @@ -65,8 +65,11 @@ const FLAP_TIMES: usize = 3; /// 撑过这么久的 critical,恢复时值得说一句;短的静默撤回 const SAY_RECOVERED_AFTER: Duration = Duration::from_secs(300); -/// 一次性的事([`Signal::event`])弹过一次之后,这么久里再发生只记下、不再弹;冷却结束时 -/// 还有没说的,合成一条说。 +/// 一次性的事([`Signal::event`])弹过一次之后,这么久里再发生只记下、不再弹。 +/// +/// **冷却结束时不补发**:冷却期间又发生的,列表里那一条累计次数、重新算没看过,通知中心里 +/// 那一份原地改掉(不打断)。事后补的一条说的是几分钟前的事,那时那次请求、那段会话多半 +/// 已经结束了,用户对不上;冷却过后再发生,照常当场弹。 /// /// **5 分钟**:一次拦截发生时客户端正在弹批准提示,第一条必须立刻说;之后几分钟里同一家 /// 再拦下的,几乎总是同一件事的后续(代理在重试同一个调用、同一段会话接着往下走),用户 @@ -229,8 +232,8 @@ impl Signal { /// 铃铛、菜单栏、系统通知都不再有动静 —— 而记录是落盘的,重启之后也一样。所以: /// /// - 每发生一次,列表里那一条都**重新算没看过**,次数和内容换成最近的这一次 - /// - 系统通知按 [`COOLDOWN`] 限着:弹过一次之后,冷却期间再发生只记下;冷却结束时 - /// 还有没说的、用户也还没看过,合成一条再说一次 + /// - 系统通知按 [`COOLDOWN`] 限着:弹过一次之后,冷却期间再发生只记下、不再弹,冷却 + /// 结束时也不补;之后再发生照常当场弹 /// - 不去抖:它不会自己好 pub fn event(mut self) -> Self { self.event = true; @@ -311,14 +314,11 @@ struct Open { muted_until: Option, /// 这一条是抖动之后留下的那句「(时断时续)」说明,不是一件还开着的事 intermittent: bool, - /// 一次性的事上次弹出之后,冷却到什么时候([`COOLDOWN`])。用 tokio 的钟:和冷却结束 - /// 时的那个计时器是同一个钟,测试里拨得动。 + /// 一次性的事上次弹出之后,冷却到什么时候([`COOLDOWN`])。用 tokio 的钟:测试里拨得动。 /// /// **只在内存里,不落盘**:重启之后头一次再发生照常弹。宁可重启之后多说一次,也不能 /// 让存下来的状态把以后的每一次都压住 cool_until: Option, - /// 冷却期间又发生、还没为它弹过的次数。冷却结束时合成一条说 - unsaid: u32, /// 要弹时和同时要弹的合成一条([`Signal::merged`]),这是它那一行 merge: Option, } @@ -386,7 +386,6 @@ impl Notices { muted_until: None, intermittent: false, cool_until: None, - unsaid: 0, merge: None, }, ) @@ -651,7 +650,7 @@ impl Notices { } let now = Instant::now(); let tick = tokio::time::Instant::now(); - let (notice, deliver, due, sum_up) = { + let (notice, deliver, due) = { let mut g = self.state.lock().expect("锁未中毒"); let existing = g.open.get(&signal.key).cloned(); let muted = existing.as_ref().and_then(|o| o.muted_until); @@ -659,9 +658,7 @@ impl Notices { let escalated = existing .as_ref() .is_some_and(|o| signal.level > o.notice.level); - let (mut cool_until, mut unsaid) = existing - .as_ref() - .map_or((None, 0), |o| (o.cool_until, o.unsaid)); + let mut cool_until = existing.as_ref().and_then(|o| o.cool_until); let cooling = signal.event && cool_until.is_some_and(|t| tick < t); let notice = match existing { // 一次性的事又发生了一次:**是新的一件**,重新算没看过 @@ -715,18 +712,8 @@ impl Notices { let merged = signal.merge.is_some(); let deliver = wants && !signal.hold && (merged || take_token(&mut g, &signal.key, signal.level)); - let mut sum_up = None; - if signal.event { - if deliver { - cool_until = Some(tick + COOLDOWN); - unsaid = 0; - } else if interrupts && !hush && cooling { - // 只差冷却这一条:记下,冷却结束时合成一条说。头一次记下时排上那个时刻 - if unsaid == 0 { - sum_up = cool_until; - } - unsaid = unsaid.saturating_add(signal.times); - } + if signal.event && deliver { + cool_until = Some(tick + COOLDOWN); } g.open.insert( signal.key.clone(), @@ -736,11 +723,10 @@ impl Notices { muted_until: muted, intermittent: false, cool_until, - unsaid, merge: signal.merge.clone(), }, ); - (notice, deliver, due, sum_up) + (notice, deliver, due) }; if deliver { self.deliver(¬ice); @@ -753,9 +739,6 @@ impl Notices { if let Some(at) = due { self.schedule(signal.key.clone(), at); } - if let Some(at) = sum_up { - self.sum_up(signal.key.clone(), at); - } self.persist(); self.changed(); } @@ -804,48 +787,6 @@ impl Notices { }); } - /// 一次性的事冷却结束(`at`)。**期间又发生过、用户还没看的,合成一条说**,说了就再 - /// 冷却一段。用户已经看过了、换了一档、这会儿被更要紧的事压着的,就不说了 —— 列表里 - /// 那一条一直在,次数也一直在涨 - fn sum_up(self: &Arc, key: String, at: tokio::time::Instant) { - let me = self.clone(); - tokio::spawn(async move { - tokio::time::sleep_until(at).await; - let wants = me.mode() == Mode::System; - let shown = { - let mut g = me.state.lock().expect("锁未中毒"); - // 还开着、冷却的还是这一段、期间有没说的 - let pending = g - .open - .get(&key) - .filter(|o| o.cool_until == Some(at) && o.unsaid > 0) - .map(|o| (o.notice.clone(), o.unsaid, o.muted_until, o.merge.is_some())); - let Some((notice, unsaid, muted, merged)) = pending else { - return; - }; - let say = wants - && !notice.read - && !hushed(&g, &key, muted, Instant::now()) - && (merged || take_token(&mut g, &key, notice.level)); - if let Some(o) = g.open.get_mut(&key) { - o.unsaid = 0; - if say { - o.cool_until = Some(tokio::time::Instant::now() + COOLDOWN); - } - } - say.then(|| Notice { - body: summed_up(¬ice.body, unsaid), - ..notice - }) - }; - if let Some(n) = shown { - me.deliver(&n); - me.persist(); - me.changed(); - } - }); - } - fn deliver(self: &Arc, notice: &Notice) { if self.merges(¬ice.key) { self.merge_later(¬ice.key); @@ -1012,7 +953,6 @@ impl Notices { muted_until: Some(now + FLAP_WINDOW), intermittent: true, cool_until: None, - unsaid: 0, merge: None, }, ); @@ -1127,18 +1067,6 @@ fn newest_first(state: &State) -> Vec { out } -/// 冷却结束时合成的那一条的正文:最近那一次的内容,再带一句上次弹出之后又发生了几次 -fn summed_up(body: &str, n: u32) -> String { - tr!( - format!("{body}(上次通知后又发生 {n} 次)"), - if n == 1 { - format!("{body} (1 more time since the last notification)") - } else { - format!("{body} ({n} more times since the last notification)") - } - ) -} - /// 此刻不该为 `key` 打断用户:它在抖(`muted_until` 之前),或者有更要紧的事压着它。 /// 新来的时候判一次,去抖到点时再判一次([`Notices::schedule`])—— 两处必须是同一套 fn hushed(state: &State, key: &str, muted_until: Option, now: Instant) -> bool { diff --git a/src-tauri/src/notices/tests.rs b/src-tauri/src/notices/tests.rs index ed5964b..d704e9c 100644 --- a/src-tauri/src/notices/tests.rs +++ b/src-tauri/src/notices/tests.rs @@ -445,7 +445,7 @@ async fn wait(d: Duration) { } /// 每拦下一次都是一件新的事。**看过之后再拦下,铃铛和菜单栏立刻重新数它**(它们只数没 -/// 看过的);冷却期间不再弹,冷却结束时合成一条说。以前它按「同一件事」去重:看过一次 +/// 看过的);冷却期间不再弹,冷却结束时也不补。以前它按「同一件事」去重:看过一次 /// 之后,再拦下多少次都只是悄悄涨个次数 #[tokio::test(start_paused = true)] async fn a_block_after_the_last_one_was_read_counts_again_at_once() { @@ -459,7 +459,7 @@ async fn a_block_after_the_last_one_was_read_counts_again_at_once() { assert_eq!(n.count, 2); assert_eq!(b.titles().len(), 1, "冷却期间不再弹:{:?}", b.titles()); wait(COOLDOWN).await; - assert_eq!(b.titles().len(), 2, "冷却结束时要说:{:?}", b.titles()); + assert_eq!(b.titles().len(), 1, "冷却结束时不补:{:?}", b.titles()); } #[tokio::test(start_paused = true)] @@ -474,10 +474,11 @@ async fn once_the_cooldown_is_over_the_next_block_pops_right_away() { assert_eq!(b.bus.list()[0].count, 2); } -/// 一阵子里接连拦下好多次:**只弹一条**,其余的进列表、累计次数,不刷屏;冷却结束时还有 -/// 没说的,合成一条再说一次,之后没有再拦下就安静。另一家不受这一家的冷却影响 +/// 一阵子里接连拦下好多次:**只弹一条**,其余的进列表、累计次数,不刷屏。**冷却结束时 +/// 不补发**:几分钟后补的一条说的是那次会话早就过去的事。另一家不受这一家的冷却影响; +/// 冷却过后再拦下,当场弹 #[tokio::test(start_paused = true)] -async fn a_burst_of_blocks_pops_once_and_is_summed_up_when_the_cooldown_ends() { +async fn a_burst_of_blocks_pops_once_and_nothing_comes_late() { let b = bed(); for i in 0..10 { b.bus.ingest(blocked("relay"), T0 + i * 1_000); @@ -488,20 +489,28 @@ async fn a_burst_of_blocks_pops_once_and_is_summed_up_when_the_cooldown_ends() { b.bus.ingest(blocked("other"), T0 + 10_000); assert_eq!(b.titles().len(), 2, "另一家照常说:{:?}", b.titles()); - wait(COOLDOWN).await; - assert_eq!(b.titles().len(), 3, "{:?}", b.titles()); - let last = b.bodies().last().cloned().unwrap_or_default(); - assert!( - last.contains("relay") && last.contains("又发生 9 次"), - "{last}" - ); wait(COOLDOWN * 3).await; - assert_eq!(b.titles().len(), 3, "之后没有再拦下,不再说"); + assert_eq!(b.titles().len(), 2, "冷却结束时不补:{:?}", b.titles()); + assert_eq!( + b.bus + .list() + .iter() + .find(|n| n.key == "toolwall:relay") + .map(|n| n.count), + Some(10) + ); + b.bus.ingest(blocked("relay"), T0 + 1_000_000); + assert_eq!( + b.titles().len(), + 3, + "冷却过后再拦下,当场弹:{:?}", + b.titles() + ); } -/// 这一阵子拦下的,用户在冷却结束之前都看过了:没有要合成的 +/// 这一阵子拦下的,用户在冷却结束之前都看过了:之后什么都不说 #[tokio::test(start_paused = true)] -async fn a_burst_that_was_read_before_the_cooldown_ended_is_not_summed_up() { +async fn a_burst_that_was_read_before_the_cooldown_ended_stays_quiet() { let b = bed(); for i in 0..3 { b.bus.ingest(blocked("relay"), T0 + i * 1_000); @@ -820,11 +829,10 @@ async fn a_plugin_failing_on_every_answer_is_gathered_not_flooded() { assert_eq!(bus.list()[0].count, 52); assert_eq!(pushes(), after_first + 2); - // 冷却结束时合成一条说,带上这期间又发生的次数 + // 冷却结束时不补发:次数在列表里 wait(COOLDOWN).await; let all = shown.lock().unwrap().clone(); - assert_eq!(all.len(), 2, "{all:?}"); - assert!(all[1].contains("51"), "{}", all[1]); + assert_eq!(all.len(), 1, "{all:?}"); } /// 两个插件各攒各的:一个在攒着,另一个的头一次照样立刻说 @@ -1058,7 +1066,7 @@ async fn hits_that_arrive_together_are_told_in_one_notification() { } } -/// 同一条规则 5 分钟内至多通知一次;冷却结束时还有没说的,合成一条再说 +/// 同一条规则 5 分钟内至多通知一次;冷却结束时不补发 #[tokio::test(start_paused = true)] async fn the_same_rule_is_told_at_most_once_in_five_minutes() { let b = bed(); @@ -1072,14 +1080,18 @@ async fn the_same_rule_is_told_at_most_once_in_five_minutes() { wait(MERGE).await; assert_eq!(b.titles().len(), 1, "冷却期间不再弹:{:?}", b.titles()); assert_eq!(b.bus.list()[0].count, 3, "次数照记"); - wait(COOLDOWN).await; - wait(MERGE).await; - assert_eq!(b.titles().len(), 2, "冷却结束时说一次:{:?}", b.titles()); // 另一条规则不受这一条的冷却影响 for s in rules::from_event(&secrets("relay", &[("email", true)])) { b.bus.ingest(s, T0 + 130_000); } wait(MERGE).await; + assert_eq!(b.titles().len(), 2, "{:?}", b.titles()); + wait(COOLDOWN).await; + wait(MERGE).await; + assert_eq!(b.titles().len(), 2, "冷却结束时不补:{:?}", b.titles()); + // 冷却过后再命中,当场弹 + b.bus.ingest(hit(), T0 + 600_000); + wait(MERGE).await; assert_eq!(b.titles().len(), 3, "{:?}", b.titles()); } @@ -1537,16 +1549,6 @@ fn the_bus_adds_its_own_words_in_english_too() { b.titles().last().unwrap(), "Resolved: Gateway Not Forwarding" ); - - // 冷却结束时合成的那一条 - assert_eq!( - summed_up("It matched the rule.", 1), - "It matched the rule. (1 more time since the last notification)" - ); - assert_eq!( - summed_up("It matched the rule.", 9), - "It matched the rule. (9 more times since the last notification)" - ); }); }