term_msg: carry the source event's time on every row

A terminal row published on `$SWARM.term.<hive>.<agent>` goes out bare,
with no envelope around it and no server-side stamp, so a subscriber had
nothing to place the row in time with beyond its own receipt clock —
wrong by the queue's latency and meaningless for anything read later
than live.

`TermMsg` gains `ts`, ISO 8601 UTC. `classify` takes the event's own
unix-seconds stamp and applies it to every row that event expands into,
so a row replayed out of sqlite says when it happened rather than when
it was read, and a row that sat in a lagging subscriber's buffer does
not lie about its time. The oversize degrade keeps it; only the body is
ever spent.

`TermEnvelope` stops duplicating `ts` and keeps `seq`: the dedup counter
is a real transport concern, the event's time is not, now that it rides
on the row. Nothing in the frontend read `envelope.ts` — only the type
declared it.

Refs #4321
This commit is contained in:
atlas 2026-09-13 14:01:14 +02:00 committed by mara
commit 053340128b
5 changed files with 206 additions and 38 deletions

View file

@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream};
use super::AppState;
use crate::term_msg::{ClassifyCtx, Level, TermMsg, classify};
use crate::term_msg::{ClassifyCtx, Level, TermMsg, classify, iso8601_utc};
/// One classified envelope on the wire: transport-level metadata (a sibling
/// of the terminal-row payload, not part of it) plus the zero-or-more rows
@ -20,14 +20,15 @@ use crate::term_msg::{ClassifyCtx, Level, TermMsg, classify};
///
/// `seq` is the live per-event dedup counter (`BusEvent::seq`) — `Some` on
/// the SSE path, `None` on history replay (a stored row has no live seq).
/// Same category of plumbing as `ts`: the client already used it to drop
/// buffered live traffic it's about to see again in the initial history
/// page, and that need didn't go away just because rows lost their `kind`
/// tag — dropping it here would silently reintroduce duplicate rows across
/// the live/history boundary.
/// The client uses it to drop buffered live traffic it's about to see again
/// in the initial history page, and that need didn't go away just because
/// rows lost their `kind` tag — dropping it here would silently reintroduce
/// duplicate rows across the live/history boundary. It is the one piece of
/// transport plumbing left: the event's time now rides on each row's own
/// `ts` (`crate::term_msg`), which is where a consumer with no envelope
/// around it — the swarm queue's — can also read it.
#[derive(Serialize)]
pub(super) struct TermEnvelope {
ts: i64,
#[serde(skip_serializing_if = "Option::is_none")]
seq: Option<u64>,
msgs: Vec<TermMsg>,
@ -87,15 +88,11 @@ pub(super) async fn events_history(
let events: Vec<TermEnvelope> = events
.into_iter()
.filter_map(|se| {
let msgs = classify(&se.event, &mut ctx);
let msgs = classify(&se.event, se.ts, &mut ctx);
if msgs.is_empty() {
None
} else {
Some(TermEnvelope {
ts: se.ts,
seq: None,
msgs,
})
Some(TermEnvelope { seq: None, msgs })
}
})
.collect();
@ -117,10 +114,15 @@ pub(super) async fn events_stream(
// stream rather than emitted to the bus — a bus emit would spam every
// already-connected client with a spurious note each time anyone opens
// the stream.
// Synthesised here rather than classified from a bus event, so this is
// the one row that stamps itself: its "source event" is the subscribe
// that just happened.
let hello_envelope = TermEnvelope {
ts: chrono::Utc::now().timestamp(),
seq: None,
msgs: vec![TermMsg::new(Level::Debug, "live stream attached")],
msgs: vec![
TermMsg::new(Level::Debug, "live stream attached")
.at(iso8601_utc(chrono::Utc::now().timestamp())),
],
};
let hello = Event::default().data(serde_json::to_string(&hello_envelope).unwrap_or_default());
// One `ClassifyCtx` per connection, moved into the closure — tool_use→
@ -130,12 +132,11 @@ pub(super) async fn events_stream(
let mut ctx = ClassifyCtx::default();
let live = BroadcastStream::new(rx).filter_map(move |res| {
let ev = res.ok()?;
let msgs = classify(&ev.event, &mut ctx);
let msgs = classify(&ev.event, ev.ts, &mut ctx);
if msgs.is_empty() {
return None;
}
let envelope = TermEnvelope {
ts: ev.ts,
seq: Some(ev.seq),
msgs,
};