sse: seq plumbing + subscribe-first dedupe dance
This commit is contained in:
parent
8c186d4fb7
commit
1340a654e7
5 changed files with 197 additions and 37 deletions
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -191,6 +191,12 @@ async fn serve_shared_js() -> impl IntoResponse {
|
|||
|
||||
#[derive(Serialize)]
|
||||
struct StateSnapshot {
|
||||
/// Bus seq at the moment this snapshot was assembled. Clients dedupe
|
||||
/// their buffered SSE traffic against this value: events with
|
||||
/// `seq <= snapshot.seq` are already reflected (or pre-date the
|
||||
/// snapshot); `seq > snapshot.seq` is post-snapshot. Reset to 0 on
|
||||
/// harness restart — clients treat reconnect as a fresh world.
|
||||
seq: u64,
|
||||
label: String,
|
||||
dashboard_port: u16,
|
||||
/// `"online"` | `"needs_login_idle"` | `"needs_login_in_progress"`.
|
||||
|
|
@ -226,6 +232,9 @@ struct SessionView {
|
|||
}
|
||||
|
||||
async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
||||
// Capture seq *before* any reads so the dedupe contract is
|
||||
// "events with seq > snapshot.seq are post-snapshot, never missed."
|
||||
let seq = state.bus.current_seq();
|
||||
drop_if_finished(&state.session);
|
||||
let login = *state.login.lock().unwrap();
|
||||
let session_snapshot = state.session.lock().unwrap().clone();
|
||||
|
|
@ -251,6 +260,7 @@ async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
|||
let model = state.bus.model();
|
||||
let token_usage = state.bus.last_usage();
|
||||
axum::Json(StateSnapshot {
|
||||
seq,
|
||||
label: state.label.clone(),
|
||||
dashboard_port,
|
||||
status,
|
||||
|
|
@ -338,10 +348,15 @@ async fn post_send(State(state): State<AppState>, Form(form): Form<SendForm>) ->
|
|||
}
|
||||
}
|
||||
|
||||
async fn events_history(
|
||||
State(state): State<AppState>,
|
||||
) -> axum::Json<Vec<crate::events::LiveEvent>> {
|
||||
axum::Json(state.bus.history())
|
||||
async fn events_history(State(state): State<AppState>) -> axum::Json<serde_json::Value> {
|
||||
// Capture seq *before* the read so dedupe is "drop buffered events
|
||||
// you've already seen in history", never "lose an event that fired
|
||||
// between the read and the timestamp." Historical rows have no
|
||||
// per-row seq; only the high-water mark matters for the dedupe
|
||||
// window.
|
||||
let seq = state.bus.current_seq();
|
||||
let events = state.bus.history();
|
||||
axum::Json(serde_json::json!({ "seq": seq, "events": events }))
|
||||
}
|
||||
|
||||
async fn events_stream(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue