sse: seq plumbing + subscribe-first dedupe dance

This commit is contained in:
müde 2026-05-17 12:26:00 +02:00
parent 8c186d4fb7
commit 1340a654e7
5 changed files with 197 additions and 37 deletions

View file

@ -9,7 +9,7 @@
//! showing "connecting…" until the first event arrives.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use rusqlite::{Connection, params};
@ -74,6 +74,18 @@ CREATE TABLE IF NOT EXISTS events (
CREATE INDEX IF NOT EXISTS idx_events_ts ON events (ts);
";
/// Envelope carried over the broadcast channel: the `LiveEvent` itself
/// plus a monotonic per-process seq stamped by `Bus::emit`. SSE consumers
/// serialize this directly (seq becomes a sibling of the `kind` tag);
/// clients use seq to dedupe their buffered live traffic against the
/// snapshot/history responses (drop anything with `seq <= snapshot.seq`).
#[derive(Debug, Clone, Serialize)]
pub struct BusEvent {
pub seq: u64,
#[serde(flatten)]
pub event: LiveEvent,
}
/// One row of the agent's live stream. Serialised to JSON for SSE delivery.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
@ -216,7 +228,13 @@ pub const DEFAULT_MODEL: &str = "haiku";
#[derive(Clone)]
pub struct Bus {
tx: Arc<broadcast::Sender<LiveEvent>>,
tx: Arc<broadcast::Sender<BusEvent>>,
/// Monotonic per-process counter stamped onto every `BusEvent`.
/// Persisted nowhere — a harness restart resets seq to 0; clients
/// always treat reconnect as "fresh state, fresh stream of seqs."
/// Historical events served from sqlite carry no seq (they predate
/// the live channel the seq is meant to dedupe against).
event_seq: Arc<AtomicU64>,
/// Persistent event log. `None` only if opening the sqlite db failed
/// at construction — we keep going so the harness doesn't die on a
/// missing state dir mount in dev / test scenarios.
@ -258,6 +276,7 @@ impl Bus {
let initial_model = load_model().unwrap_or_else(|| DEFAULT_MODEL.to_owned());
Self {
tx: Arc::new(tx),
event_seq: Arc::new(AtomicU64::new(0)),
store,
state: Arc::new(Mutex::new((TurnState::Idle, now_unix()))),
model: Arc::new(Mutex::new(initial_model)),
@ -266,6 +285,20 @@ impl Bus {
}
}
/// Current high-water seq. Snapshot endpoints read this before
/// gathering state so the resulting (snapshot.seq, snapshot) pair
/// satisfies: any live event with seq > snapshot.seq is post-snapshot
/// (not yet reflected). Clients dedupe buffered SSE traffic against
/// this value.
#[must_use]
pub fn current_seq(&self) -> u64 {
self.event_seq.load(Ordering::SeqCst)
}
fn next_seq(&self) -> u64 {
self.event_seq.fetch_add(1, Ordering::SeqCst) + 1
}
/// Arm the one-shot: the next claude invocation will run without
/// `--continue`, dropping any prior session context. Idempotent
/// — calling twice in a row before the next turn still consumes
@ -333,11 +366,15 @@ impl Bus {
{
tracing::warn!(error = ?e, "events: append failed");
}
let envelope = BusEvent {
seq: self.next_seq(),
event,
};
// Lagged subscribers drop events — fine; the UI is a tail, not a log.
let _ = self.tx.send(event);
let _ = self.tx.send(envelope);
}
pub fn subscribe(&self) -> broadcast::Receiver<LiveEvent> {
pub fn subscribe(&self) -> broadcast::Receiver<BusEvent> {
self.tx.subscribe()
}