Skip to main content

ai_crew_sync/
events.rs

1//! In-process event hub fed by Postgres LISTEN/NOTIFY.
2//!
3//! One background task holds a single LISTEN connection on `bus_events` and
4//! fans every payload out to in-process subscribers through a tokio broadcast
5//! channel. Consumers: the `wait_for_updates` tool (long-poll) and the webhook
6//! dispatcher. Payloads carry ids only; consumers resolve names against the
7//! database when they need them.
8
9use std::{
10    sync::{
11        Arc,
12        atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering},
13    },
14    time::Duration,
15};
16
17use sqlx::{PgPool, postgres::PgListener};
18use tokio::sync::broadcast;
19use tokio_util::sync::CancellationToken;
20use uuid::Uuid;
21
22pub const PG_CHANNEL: &str = "bus_events";
23
24/// A parsed NOTIFY payload. Kept as loose JSON plus typed accessors so adding
25/// fields to the triggers never breaks older consumers.
26#[derive(Clone, Debug)]
27pub struct BusEvent(pub serde_json::Value);
28
29impl BusEvent {
30    pub fn kind(&self) -> &str {
31        self.0.get("kind").and_then(|v| v.as_str()).unwrap_or("")
32    }
33
34    fn uuid_field(&self, key: &str) -> Option<Uuid> {
35        self.0
36            .get(key)
37            .and_then(|v| v.as_str())
38            .and_then(|s| Uuid::parse_str(s).ok())
39    }
40
41    pub fn team_id(&self) -> Option<Uuid> {
42        self.uuid_field("team_id")
43    }
44
45    pub fn recipient_agent_id(&self) -> Option<Uuid> {
46        self.uuid_field("recipient_agent_id")
47    }
48
49    pub fn sender_agent_id(&self) -> Option<Uuid> {
50        self.uuid_field("sender_agent_id")
51    }
52
53    pub fn channel_id(&self) -> Option<Uuid> {
54        self.uuid_field("channel_id")
55    }
56
57    pub fn message_id(&self) -> Option<i64> {
58        self.0.get("id").and_then(|v| v.as_i64())
59    }
60
61    /// Working context this message was addressed to, if it was addressed to
62    /// one. Absent means every session of the recipient.
63    pub fn recipient_session(&self) -> Option<&str> {
64        self.0.get("recipient_session").and_then(|v| v.as_str())
65    }
66
67    /// Working context the message was sent from, if any.
68    pub fn sender_session(&self) -> Option<&str> {
69        self.0.get("sender_session").and_then(|v| v.as_str())
70    }
71
72    /// Was this posted as an announcement — something the sender judged worth
73    /// interrupting the whole team for?
74    pub fn is_announcement(&self) -> bool {
75        self.0
76            .get("announce")
77            .and_then(|v| v.as_bool())
78            .unwrap_or(false)
79    }
80
81    pub fn is_direct_message(&self) -> bool {
82        self.kind() == "message" && self.recipient_agent_id().is_some()
83    }
84
85    /// Is this event visible to `session` of `agent` of `team`?
86    ///
87    /// Direct messages are only visible to their recipient (and sender);
88    /// everything else is team-wide. A message addressed to one session does
89    /// not wake the recipient's other sessions — otherwise every window of a
90    /// person would wake for a question meant for one of them. It still
91    /// reaches every session of the *sender*, which is how a reply finds the
92    /// window that is blocked waiting for it.
93    pub fn visible_to(&self, team_id: Uuid, agent_id: Uuid, session: &str) -> bool {
94        if self.team_id() != Some(team_id) {
95            return false;
96        }
97        if self.is_direct_message() {
98            if self.sender_agent_id() == Some(agent_id) {
99                return true;
100            }
101            if self.recipient_agent_id() != Some(agent_id) {
102                return false;
103            }
104            return match self.recipient_session() {
105                Some(addressed) => addressed == session,
106                // Addressed to the person: every one of their sessions.
107                None => true,
108            };
109        }
110        true
111    }
112}
113
114/// How often each replica writes a ping through Postgres and expects to hear
115/// it back on its own LISTEN connection.
116///
117/// The echo is the only proof the listener is alive. A connection that only
118/// ever reads cannot tell a quiet channel from a peer that silently dropped
119/// it: Swarm's IPVS forgets an idle TCP connection after fifteen minutes and
120/// tells neither end, and every wake on a deployment stopped while the log
121/// still said "attached". A ping is also traffic, so a listener that echoes
122/// is one no idle timeout forgets.
123pub const DEFAULT_PING_SECS: u64 = 30;
124
125/// Pings sent without an echo before the listener is declared deaf, dropped
126/// and attached afresh.
127const MISSED_ECHOES: u32 = 3;
128
129/// What the listener knows about itself, for `/health` and for the loop.
130pub struct ListenerHealth {
131    ping_every: Duration,
132    attached: AtomicBool,
133    /// Unix seconds of the last own ping heard back; reset on attach.
134    last_echo: AtomicI64,
135    /// LISTEN connections established so far. A second one means the first
136    /// was lost, silently or not.
137    attachments: AtomicU64,
138    echoes: AtomicU64,
139}
140
141fn unix_now() -> i64 {
142    chrono::Utc::now().timestamp()
143}
144
145impl ListenerHealth {
146    fn new(ping_every: Duration) -> Self {
147        Self {
148            ping_every,
149            attached: AtomicBool::new(false),
150            last_echo: AtomicI64::new(0),
151            attachments: AtomicU64::new(0),
152            echoes: AtomicU64::new(0),
153        }
154    }
155
156    pub fn ping_every(&self) -> Duration {
157        self.ping_every
158    }
159
160    /// How long without an echo before "live" becomes "silent": the same
161    /// span after which the loop gives up on the connection.
162    pub fn stale_after(&self) -> Duration {
163        self.ping_every * MISSED_ECHOES
164    }
165
166    /// A fresh connection has proven nothing yet: it is attached and silent
167    /// until its first ping comes back, on every attach, not only the first.
168    fn attached_now(&self) {
169        self.last_echo.store(0, Ordering::SeqCst);
170        self.attached.store(true, Ordering::SeqCst);
171        self.attachments.fetch_add(1, Ordering::SeqCst);
172    }
173
174    fn echoed(&self) {
175        self.last_echo.store(unix_now(), Ordering::SeqCst);
176        self.echoes.fetch_add(1, Ordering::SeqCst);
177    }
178
179    fn detached(&self) {
180        self.attached.store(false, Ordering::SeqCst);
181    }
182
183    /// `live`: attached and hearing itself. `silent`: attached, but nothing
184    /// heard back yet on this connection, or not within the deadline — what
185    /// a dead socket looks like until the loop drops it. `detached`: no
186    /// LISTEN connection right now.
187    pub fn report(&self) -> serde_json::Value {
188        let attached = self.attached.load(Ordering::SeqCst);
189        let last_echo = self.last_echo.load(Ordering::SeqCst);
190        let age = (last_echo > 0).then(|| unix_now() - last_echo);
191        let listener = if !attached {
192            "detached"
193        } else if age.is_some_and(|a| a <= self.stale_after().as_secs() as i64) {
194            "live"
195        } else {
196            "silent"
197        };
198        serde_json::json!({
199            "listener": listener,
200            "last_echo_seconds": age.filter(|_| attached),
201            "attachments": self.attachments.load(Ordering::SeqCst),
202            "echoes": self.echoes.load(Ordering::SeqCst),
203            "ping_seconds": self.ping_every.as_secs(),
204        })
205    }
206
207    pub fn is_live(&self) -> bool {
208        self.report()["listener"] == "live"
209    }
210}
211
212#[derive(Clone)]
213pub struct EventHub {
214    tx: broadcast::Sender<BusEvent>,
215    health: Arc<ListenerHealth>,
216}
217
218impl Default for EventHub {
219    fn default() -> Self {
220        Self::new()
221    }
222}
223
224impl EventHub {
225    pub fn new() -> Self {
226        Self::with_ping(Duration::from_secs(DEFAULT_PING_SECS))
227    }
228
229    /// A hub whose listener pings itself every `ping_every` (at least one
230    /// second: a zero interval would be a busy loop against Postgres).
231    pub fn with_ping(ping_every: Duration) -> Self {
232        // 256 in-flight events is plenty; laggards get Lagged and resync from
233        // the database, which every consumer does anyway.
234        let (tx, _) = broadcast::channel(256);
235        Self {
236            tx,
237            health: Arc::new(ListenerHealth::new(ping_every.max(Duration::from_secs(1)))),
238        }
239    }
240
241    pub fn listener(&self) -> &ListenerHealth {
242        &self.health
243    }
244
245    pub fn subscribe(&self) -> broadcast::Receiver<BusEvent> {
246        self.tx.subscribe()
247    }
248
249    pub fn publish(&self, event: BusEvent) {
250        // No receivers is fine: nobody is waiting right now.
251        let _ = self.tx.send(event);
252    }
253}
254
255/// Run the LISTEN loop until cancelled. Reconnects with backoff on failure so
256/// a Postgres restart degrades to polling latency instead of killing wakeups.
257///
258/// The connection is also watched from the inside: every `ping_every` this
259/// replica notifies the channel with its own id through the pool and expects
260/// to read that ping back here. [`MISSED_ECHOES`] pings without an echo mean
261/// the socket is dead however open it looks, and the listener is dropped
262/// and attached afresh rather than trusted forever.
263pub async fn run_pg_listener(pool: PgPool, hub: EventHub, ct: CancellationToken) {
264    let health = hub.listener();
265    let replica = Uuid::new_v4().to_string();
266    loop {
267        if ct.is_cancelled() {
268            return;
269        }
270        match PgListener::connect_with(&pool).await {
271            Ok(mut listener) => {
272                if let Err(e) = listener.listen(PG_CHANNEL).await {
273                    tracing::warn!(error = %e, "LISTEN failed; retrying");
274                } else {
275                    tracing::info!("event listener attached to '{PG_CHANNEL}'");
276                    health.attached_now();
277                    let mut ticker = tokio::time::interval(health.ping_every());
278                    ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
279                    // Pings sent on this connection that have not come back.
280                    let mut unanswered: u32 = 0;
281                    loop {
282                        tokio::select! {
283                            _ = ct.cancelled() => return,
284                            _ = ticker.tick() => {
285                                if unanswered >= MISSED_ECHOES {
286                                    tracing::warn!(
287                                        unanswered,
288                                        "event listener has not heard its own ping; the \
289                                         connection is dead however open it looks. Reattaching"
290                                    );
291                                    break;
292                                }
293                                unanswered += 1;
294                                let ping = serde_json::json!({ "kind": "ping", "replica": replica })
295                                    .to_string();
296                                if let Err(e) = sqlx::query("SELECT pg_notify($1, $2)")
297                                    .bind(PG_CHANNEL)
298                                    .bind(&ping)
299                                    .execute(&pool)
300                                    .await
301                                {
302                                    tracing::warn!(error = %e, "could not ping the event channel");
303                                }
304                            }
305                            recv = listener.try_recv() => match recv {
306                                Ok(Some(notification)) => {
307                                    match serde_json::from_str(notification.payload()) {
308                                        Ok(value) => {
309                                            let event = BusEvent(value);
310                                            if event.kind() == "ping" {
311                                                // Every replica's pings arrive here; only
312                                                // this one's say anything about this
313                                                // connection. None of them is an event.
314                                                if event.0.get("replica").and_then(|v| v.as_str())
315                                                    == Some(replica.as_str())
316                                                {
317                                                    unanswered = 0;
318                                                    health.echoed();
319                                                }
320                                                continue;
321                                            }
322                                            hub.publish(event)
323                                        }
324                                        Err(e) => tracing::warn!(
325                                            error = %e,
326                                            payload = notification.payload(),
327                                            "unparseable bus event"
328                                        ),
329                                    }
330                                }
331                                // None = connection dropped and was re-established by
332                                // sqlx; notifications in between are lost, which
333                                // consumers tolerate by re-checking the database. The
334                                // next echo says whether the new connection hears.
335                                Ok(None) => tracing::debug!("event listener reconnected"),
336                                Err(e) => {
337                                    tracing::warn!(error = %e, "event listener error");
338                                    break;
339                                }
340                            }
341                        }
342                    }
343                    health.detached();
344                }
345            }
346            Err(e) => {
347                tracing::warn!(error = %e, "could not attach event listener; retrying");
348            }
349        }
350        tokio::select! {
351            _ = ct.cancelled() => return,
352            _ = tokio::time::sleep(std::time::Duration::from_secs(2)) => {}
353        }
354    }
355}