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}