Skip to main content

agentmux_srv/backend/
narrated_events.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Remembers which events have already been narrated, so one background launch
5//! produces one ambient narration.
6//!
7//! WHY THIS IS PROCESS-WIDE AND NOT A LOCAL IN `register_handlers`.
8//! That function runs once per WebSocket CONNECTION, so a local set is reset by
9//! every reconnect — and a reconnect is exactly when duplicates arrive. An
10//! honored truncate clears the frontend's own `nodeIdSet` and re-parses the
11//! transcript, re-emitting every node as new, which re-invokes the narrate RPC
12//! for ids already narrated before the drop. Nothing else backstops it: the
13//! `AmbientGateway` singleton only guards calls that are concurrently in
14//! flight for a key, and releases its guard as soon as the first completes.
15//! Living on `AppState` alongside `dock_snapshots` and `pending_background_pids`
16//! is what makes the "narrate once" claim actually true. (reagent P1 on #3169.)
17//!
18//! Bounded, unlike a bare `HashSet`: this accumulates one entry per narrated
19//! event for the lifetime of the process, which for a long-lived instance
20//! running many background tasks is unbounded growth for a guard that only
21//! needs recent history. Oldest entries are evicted first — a reconnect replays
22//! recent nodes, not ancient ones, so forgetting the distant past costs nothing.
23
24use std::collections::{HashSet, VecDeque};
25
26use parking_lot::Mutex;
27
28/// How many narrated event ids to remember.
29///
30/// Generous relative to what the guard actually needs (a reconnect replays the
31/// current transcript, not the whole session) and still trivially small in
32/// memory — a few hundred short strings.
33const MAX_REMEMBERED: usize = 512;
34
35#[derive(Default)]
36struct Inner {
37    seen: HashSet<String>,
38    order: VecDeque<String>,
39}
40
41#[derive(Default)]
42pub struct NarratedEvents {
43    inner: Mutex<Inner>,
44}
45
46impl NarratedEvents {
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    /// Record `event_id` and report whether it is NEW.
52    ///
53    /// `true` means "not seen before — go ahead and narrate". `false` means it
54    /// has already been narrated and this call should do nothing.
55    pub fn mark_new(&self, event_id: &str) -> bool {
56        let mut g = self.inner.lock();
57        if !g.seen.insert(event_id.to_string()) {
58            return false;
59        }
60        g.order.push_back(event_id.to_string());
61        while g.order.len() > MAX_REMEMBERED {
62            if let Some(evicted) = g.order.pop_front() {
63                g.seen.remove(&evicted);
64            }
65        }
66        true
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn the_first_sighting_is_new_and_the_second_is_not() {
76        let n = NarratedEvents::new();
77        assert!(n.mark_new("toolu_1"));
78        assert!(!n.mark_new("toolu_1"));
79    }
80
81    #[test]
82    fn distinct_events_are_each_new() {
83        let n = NarratedEvents::new();
84        assert!(n.mark_new("toolu_1"));
85        assert!(n.mark_new("toolu_2"));
86    }
87
88    #[test]
89    fn it_stays_bounded_and_evicts_oldest_first() {
90        let n = NarratedEvents::new();
91        for i in 0..(MAX_REMEMBERED + 10) {
92            assert!(n.mark_new(&format!("id{i}")));
93        }
94        // The oldest have been forgotten — re-narrating one is the accepted
95        // cost of not growing without bound.
96        assert!(n.mark_new("id0"));
97        // Recent ones are still remembered, which is what the guard is for.
98        let newest = format!("id{}", MAX_REMEMBERED + 9);
99        assert!(!n.mark_new(&newest));
100    }
101}