Skip to main content

agent_engine/session/
transport.rs

1//! `ClientTransport` + `LocalTransport` (PLAN-phase2 §2.7).
2//!
3//! `LocalTransport` is the in-process client: same `Runtime`, same
4//! `StreamEvent` values moved through a channel, never serialised. The
5//! socket flavour (`SocketTransport`, B2) implements the same trait.
6
7use std::sync::Arc;
8
9use tokio::sync::broadcast;
10
11use super::handle::SessionHandle;
12use super::types::{
13    AttachMode, AttachSnapshot, ClientId, ClientInput, ClientMeta, Envelope, Roster, SessionCommand,
14    SessionEventWire, SessionId, SessionMeta,
15};
16use super::view::RuntimeView;
17
18#[derive(Debug, thiserror::Error)]
19pub enum TransportError {
20    #[error("session closed")]
21    Closed,
22    #[error("backpressure")]
23    Backpressure,
24    #[error("io: {0}")]
25    Io(#[from] std::io::Error),
26    #[error("protocol: {0}")]
27    Protocol(String),
28    #[error("version: client {client} daemon {daemon}")]
29    Version { client: u32, daemon: u32 },
30    /// The operation does not exist on this transport (e.g. `reconnect` on
31    /// `LocalTransport`).
32    #[error("unsupported: {0}")]
33    Unsupported(&'static str),
34    /// The daemon announced a reload and closed; `reconnect` applies.
35    #[error("daemon reloading (generation {generation})")]
36    Reloading { generation: u64 },
37    /// The daemon/actor refused the attach or command (`AttachRefused`,
38    /// `DaemonFrame::Error` during attach).
39    #[error("refused: {0}")]
40    Refused(String),
41    /// The daemon has no live session under that id
42    /// (`Refused{UnknownSession}` to a control request such as `Close`).
43    #[error("unknown session: {0}")]
44    UnknownSession(String),
45    /// The daemon predates this control request: it answered the frame
46    /// with `Error{"malformed frame: unknown variant …"}`. A
47    /// `synaps daemon reload` brings it up to this binary.
48    #[error("daemon {daemon_version} predates `{request}`; run `synaps daemon reload`")]
49    DaemonTooOld { daemon_version: String, request: &'static str },
50    /// Owned sessions: this connection, the owner, fell behind the event
51    /// stream; the daemon closed it (`Bye{owner_lagged}`) and the session
52    /// ended rather than lose frames silently.
53    #[error("owner connection lagged: {dropped} event(s) dropped; the daemon closed it and ended the session")]
54    OwnerLagged { dropped: u64 },
55}
56
57#[async_trait::async_trait]
58pub trait ClientTransport: Send {
59    fn session_id(&self) -> &SessionId;
60    fn meta(&self) -> &SessionMeta;
61    /// Host-originated send (`from: None`; bypasses input ownership).
62    async fn send(&self, cmd: SessionCommand) -> Result<(), TransportError>;
63    /// Send stamped with this client's id (the TUI uses this; ownership is
64    /// enforced by the actor, B1).
65    async fn send_from_self(&self, cmd: SessionCommand) -> Result<(), TransportError>;
66    /// Next envelope, or `None` when the session ended / socket closed.
67    async fn next_event(&mut self) -> Option<Envelope>;
68    /// Sync, cheap, never stale by more than one `SettingChanged`: what the
69    /// TUI's getters read on day 2.
70    fn view(&self) -> Arc<RuntimeView>;
71    /// Client id assigned at Attach (needed for Detach/Resync).
72    fn client_id(&self) -> ClientId;
73    /// The mode this transport attached with.
74    fn mode(&self) -> AttachMode;
75    /// Current input owner — updated from `Attached` / `InputOwnerChanged`.
76    fn input_owner(&self) -> Option<ClientId>;
77    /// Attached clients, named — updated from `Attached` / `ClientJoined` /
78    /// `ClientLeft`. Empty for a transport that doesn't track them.
79    fn roster(&self) -> Roster {
80        Roster::default()
81    }
82    /// Who may drive (from the attach snapshot). `Exclusive` for a
83    /// transport that doesn't know.
84    fn client_input(&self) -> ClientInput {
85        ClientInput::wire_default()
86    }
87    /// Socket only (Local returns `Err(Unsupported)`): after `Reloading`/EOF,
88    /// reconnect with backoff and re-attach with `mode` (C3/A4).
89    async fn reconnect(&mut self, mode: AttachMode) -> Result<AttachSnapshot, TransportError>;
90    /// F9: single reconnect attempt (no retry loop). Socket only.
91    async fn reconnect_once(&mut self, mode: AttachMode) -> Result<AttachSnapshot, TransportError> {
92        self.reconnect(mode).await
93    }
94    /// `true` if the daemon announced a reload before EOF (graceful). Always
95    /// `false` on `LocalTransport`.
96    fn is_reload_pending(&self) -> bool {
97        false
98    }
99    /// The daemon pid from `Welcome`. Returns `0` on `LocalTransport`.
100    fn daemon_pid(&self) -> u32 {
101        0
102    }
103    /// The daemon's binary from the latest `Welcome` (it changes across
104    /// `daemon reload`). `None` on `LocalTransport`: same binary.
105    fn daemon_binary(&self) -> Option<BinaryId> {
106        None
107    }
108    /// The daemon control socket this transport talks to. `None` on
109    /// `LocalTransport` (no daemon). Lets a client open its own control
110    /// connections (session list, pin, close) to the same daemon.
111    fn socket_path(&self) -> Option<&std::path::Path> {
112        None
113    }
114}
115
116/// Which binary a process runs: its version and build
117/// ([`binary_version`](super::wire::binary_version),
118/// [`build_id`](super::wire::build_id)); the build is empty when unknown.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct BinaryId {
121    pub version: String,
122    pub build: String,
123}
124
125impl BinaryId {
126    /// This process's own binary.
127    pub fn this() -> Self {
128        Self { version: super::wire::binary_version(), build: super::wire::build_id().to_string() }
129    }
130
131    /// The daemon's, from its handshake.
132    pub fn of(welcome: &super::wire::Welcome) -> Self {
133        Self { version: welcome.daemon_version.clone(), build: welcome.daemon_build.clone() }
134    }
135
136    fn label(&self, show_build: bool) -> String {
137        if show_build && !self.build.is_empty() {
138            format!("{} ({})", self.version, self.build)
139        } else {
140            self.version.clone()
141        }
142    }
143}
144
145/// When a client binary differs from the daemon it talks to: what to tell
146/// the user, else `None`. An attached TUI keeps its own binary across
147/// `daemon reload` (only the daemon re-execs), so after an upgrade it lacks
148/// every client-side change — new slash commands, tray fixes, new events —
149/// until it is restarted. Builds count, not just versions: a reload onto a
150/// newer build of the same release is the common case. An unknown build
151/// (empty: older daemon, packaged build) compares by version only.
152///
153/// The remedy depends on which side is behind. A client older than the
154/// daemon restarts to match it. A client newer than the daemon (a new
155/// install with the old daemon still running, the usual upgrade path) must
156/// not be told to restart, which relaunches the same binary: the daemon is
157/// moved onto this build with `synaps daemon reload`. Same version on
158/// another build: which one is newer is unknown, so both remedies.
159pub fn version_skew_notice(client: &BinaryId, daemon: &BinaryId, session: &str) -> Option<String> {
160    let builds_differ = !client.build.is_empty() && !daemon.build.is_empty() && client.build != daemon.build;
161    if client.version == daemon.version && !builds_differ {
162        return None;
163    }
164    let show_build = client.version == daemon.version;
165    let (c, d) = (client.label(show_build), daemon.label(show_build));
166    Some(match compare_versions(&client.version, &daemon.version) {
167        Some(std::cmp::Ordering::Less) => format!(
168            "this client is synaps {c} but the daemon is {d} — this client is older, so some \
169             commands and fixes are unavailable here. Restart the client to match the daemon: \
170             synaps --attach {session}"
171        ),
172        Some(std::cmp::Ordering::Greater) => format!(
173            "this client is synaps {c} but the daemon is {d} — the daemon is older than this \
174             client, so some commands and fixes are unavailable. To move it onto this build, \
175             run `synaps daemon reload`"
176        ),
177        _ => format!(
178            "this client is synaps {c} but the daemon is {d} — different builds, so some commands \
179             and fixes may be unavailable. If this client is the newer one, run `synaps daemon \
180             reload`; otherwise restart the client to match the daemon: synaps --attach {session}"
181        ),
182    })
183}
184
185/// Compare dotted numeric versions (`0.10.2`, a `-pre`/`+build` suffix
186/// ignored); `None` when either doesn't parse.
187fn compare_versions(a: &str, b: &str) -> Option<std::cmp::Ordering> {
188    fn parts(v: &str) -> Option<Vec<u64>> {
189        let core = v.trim().trim_start_matches('v').split(['-', '+']).next()?;
190        core.split('.').map(|p| p.parse().ok()).collect()
191    }
192    Some(parts(a)?.cmp(&parts(b)?))
193}
194
195/// Attach handshake budget.
196pub const ATTACH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
197/// Attach budget when the listed lifecycle is `Parked` (unpark ≤ 20 s, B3).
198pub const ATTACH_TIMEOUT_PARKED: std::time::Duration = std::time::Duration::from_secs(25);
199
200pub struct LocalTransport {
201    handle: SessionHandle,
202    rx: broadcast::Receiver<Envelope>,
203    client: ClientId,
204    meta: SessionMeta,
205    mode: AttachMode,
206    input_owner: Option<ClientId>,
207    roster: Roster,
208    client_input: ClientInput,
209    /// Set after `Ended` (or actor gone): `next_event` returns `None` from then on.
210    ended: bool,
211}
212
213impl LocalTransport {
214    /// Attach in-process as a `Mirror` (= `attach_with(.., Mirror)`).
215    pub async fn attach(
216        handle: SessionHandle,
217        meta: ClientMeta,
218    ) -> Result<(Self, AttachSnapshot), TransportError> {
219        Self::attach_with(handle, meta, AttachMode::Mirror).await
220    }
221
222    /// Attach in-process: sends Attach, awaits the Attached envelope
223    /// (bounded 5 s), returns the transport + snapshot.
224    pub async fn attach_with(
225        handle: SessionHandle,
226        meta: ClientMeta,
227        mode: AttachMode,
228    ) -> Result<(Self, AttachSnapshot), TransportError> {
229        // Subscribe BEFORE sending so the Attached reply cannot be missed.
230        // `Attached` is broadcast; with one attach in flight per handle at a
231        // time (every host today) the first one seen is ours.
232        let mut rx = handle.subscribe();
233        handle
234            .send(SessionCommand::Attach {
235                client: meta,
236                mode,
237                owner: None,
238            })
239            .await?;
240
241        let wait = async {
242            loop {
243                match rx.recv().await {
244                    Ok(env) => match env.event {
245                        SessionEventWire::Attached { client, snapshot } => {
246                            return Ok((client, snapshot));
247                        }
248                        SessionEventWire::AttachRefused { message } => {
249                            return Err(TransportError::Refused(message));
250                        }
251                        _ => {}
252                    },
253                    Err(broadcast::error::RecvError::Lagged(_)) => continue,
254                    Err(broadcast::error::RecvError::Closed) => {
255                        return Err(TransportError::Closed)
256                    }
257                }
258            }
259        };
260        let timeout = if handle.lifecycle() == super::types::SessionLifecycle::Parked {
261            ATTACH_TIMEOUT_PARKED
262        } else {
263            ATTACH_TIMEOUT
264        };
265        let (client, snapshot) = match tokio::time::timeout(timeout, wait).await {
266            Ok(r) => r?,
267            Err(_) => return Err(TransportError::Protocol("attach timed out".into())),
268        };
269        let meta = snapshot.meta.clone();
270        Ok((
271            Self {
272                handle,
273                rx,
274                client,
275                meta,
276                mode,
277                input_owner: snapshot.input_owner,
278                roster: Roster::from_snapshot(&snapshot),
279                client_input: snapshot.client_input,
280                ended: false,
281            },
282            snapshot,
283        ))
284    }
285
286    pub fn handle(&self) -> &SessionHandle {
287        &self.handle
288    }
289}
290
291#[async_trait::async_trait]
292impl ClientTransport for LocalTransport {
293    fn session_id(&self) -> &SessionId {
294        &self.handle.id
295    }
296
297    fn meta(&self) -> &SessionMeta {
298        &self.meta
299    }
300
301    async fn send(&self, cmd: SessionCommand) -> Result<(), TransportError> {
302        self.handle.send(cmd).await
303    }
304
305    async fn send_from_self(&self, cmd: SessionCommand) -> Result<(), TransportError> {
306        self.handle.send_from(self.client, cmd).await
307    }
308
309    async fn next_event(&mut self) -> Option<Envelope> {
310        if self.ended {
311            return None;
312        }
313        // The handle itself holds a broadcast sender, so `Closed` never
314        // fires on its own: end-of-stream is `Ended` or the actor going away
315        // (drain what it left behind first).
316        let recv = tokio::select! {
317            biased;
318            r = self.rx.recv() => r,
319            _ = self.handle.closed() => match self.rx.try_recv() {
320                Ok(env) => Ok(env),
321                Err(broadcast::error::TryRecvError::Lagged(n)) => {
322                    Err(broadcast::error::RecvError::Lagged(n))
323                }
324                Err(_) => Err(broadcast::error::RecvError::Closed),
325            },
326        };
327        match recv {
328            Ok(env) => {
329                match &env.event {
330                    SessionEventWire::Ended { .. } => self.ended = true,
331                    SessionEventWire::InputOwnerChanged { to, .. } => self.input_owner = *to,
332                    other => self.roster.apply(other),
333                }
334                Some(env)
335            }
336            Err(broadcast::error::RecvError::Lagged(n)) => {
337                // server.rs Lagged pattern: warn, tell the client once, keep going.
338                tracing::warn!(session = %self.handle.id, dropped = n, "session event stream lagged");
339                Some(Envelope {
340                    session_id: self.handle.id.clone(),
341                    seq: u64::MAX,
342                    ts: chrono::Utc::now(),
343                    event: SessionEventWire::SystemNotice(format!(
344                        "event stream lagged; {n} dropped"
345                    )),
346                })
347            }
348            Err(broadcast::error::RecvError::Closed) => {
349                self.ended = true;
350                None
351            }
352        }
353    }
354
355    fn view(&self) -> Arc<RuntimeView> {
356        Arc::clone(&self.handle.view())
357    }
358
359    fn client_id(&self) -> ClientId {
360        self.client
361    }
362
363    fn mode(&self) -> AttachMode {
364        self.mode
365    }
366
367    fn input_owner(&self) -> Option<ClientId> {
368        self.input_owner
369    }
370
371    fn roster(&self) -> Roster {
372        self.roster.clone()
373    }
374
375    fn client_input(&self) -> ClientInput {
376        self.client_input
377    }
378
379    async fn reconnect(&mut self, _mode: AttachMode) -> Result<AttachSnapshot, TransportError> {
380        Err(TransportError::Unsupported("reconnect: in-process transport"))
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    #[test]
387    fn skew_notice_only_when_the_binaries_differ() {
388        use super::{version_skew_notice, BinaryId};
389        let bin = |version: &str, build: &str| BinaryId { version: version.into(), build: build.into() };
390        assert_eq!(version_skew_notice(&bin("0.10.2", "cd1fbf50"), &bin("0.10.2", "cd1fbf50"), "s"), None);
391        // An older release.
392        let n = version_skew_notice(&bin("0.10.1", "be670c09"), &bin("0.10.2", "cd1fbf50"), "20261011-041552-79cc")
393            .unwrap();
394        assert!(n.starts_with("this client is synaps 0.10.1 but the daemon is 0.10.2 —"), "{n}");
395        assert!(n.ends_with("synaps --attach 20261011-041552-79cc"), "{n}");
396        // Same release, another build (a reload onto a newer merge batch).
397        let n = version_skew_notice(&bin("0.10.2", "ae490b32"), &bin("0.10.2", "cd1fbf50"), "s").unwrap();
398        assert!(n.starts_with("this client is synaps 0.10.2 (ae490b32) but the daemon is 0.10.2 (cd1fbf50)"), "{n}");
399        // Unknown build on either side (an older daemon, a packaged build):
400        // versions only.
401        assert_eq!(version_skew_notice(&bin("0.10.2", "ae490b32"), &bin("0.10.2", ""), "s"), None);
402        assert_eq!(version_skew_notice(&bin("0.10.2", ""), &bin("0.10.2", "cd1fbf50"), "s"), None);
403    }
404
405    /// 04-tui I3: a client NEWER than the daemon was told to restart itself,
406    /// which relaunches the same new binary. It must say to reload the daemon.
407    #[test]
408    fn skew_notice_names_the_side_that_is_behind() {
409        use super::{version_skew_notice, BinaryId};
410        let bin = |version: &str, build: &str| BinaryId { version: version.into(), build: build.into() };
411        // Client newer (the usual upgrade path): reload the daemon.
412        let n = version_skew_notice(&bin("0.10.3", "be670c09"), &bin("0.10.2", "cd1fbf50"), "s").unwrap();
413        assert!(n.starts_with("this client is synaps 0.10.3 but the daemon is 0.10.2 —"), "{n}");
414        assert!(n.contains("run `synaps daemon reload`"), "{n}");
415        assert!(!n.to_lowercase().contains("restart the client"), "{n}");
416        assert!(!n.contains("synaps --attach"), "{n}");
417        // Numeric, not lexical: 0.10.0 is newer than 0.9.9.
418        let n = version_skew_notice(&bin("0.10.0", ""), &bin("0.9.9", ""), "s").unwrap();
419        assert!(n.contains("run `synaps daemon reload`"), "{n}");
420        // Client older: restart the client, as before.
421        let n = version_skew_notice(&bin("0.9.9", ""), &bin("0.10.0", ""), "s").unwrap();
422        assert!(n.contains("Restart the client"), "{n}");
423        assert!(!n.contains("daemon reload"), "{n}");
424        // Same version, another build: which is newer is unknown; both remedies.
425        let n = version_skew_notice(&bin("0.10.2", "ae490b32"), &bin("0.10.2", "cd1fbf50"), "s").unwrap();
426        assert!(n.contains("run `synaps daemon reload`"), "{n}");
427        assert!(n.contains("restart the client"), "{n}");
428        assert!(n.contains("synaps --attach s"), "{n}");
429    }
430
431    /// An older daemon's `Welcome` has no build: it parses, build unknown.
432    #[test]
433    fn welcome_without_a_build_parses() {
434        let w: super::super::wire::Welcome = serde_json::from_value(serde_json::json!({
435            "protocol_version": 3, "daemon_version": "0.10.1", "pid": 7, "profile": null,
436            "sessions": [], "progressive_tool_disclosure": true, "generation": 2
437        }))
438        .unwrap();
439        assert_eq!(super::BinaryId::of(&w), super::BinaryId { version: "0.10.1".into(), build: String::new() });
440    }
441
442    use super::*;
443    use crate::session::types::*;
444    use crate::{LlmEvent, SessionEvent, StreamEvent};
445
446    async fn next(t: &mut LocalTransport) -> Envelope {
447        tokio::time::timeout(std::time::Duration::from_secs(2), t.next_event())
448            .await
449            .expect("timely")
450            .expect("open")
451    }
452
453    /// Debug strings of every `Stream(_)` up to and including the next `Conversation`.
454    async fn collect(t: &mut LocalTransport) -> Vec<String> {
455        let mut out = Vec::new();
456        loop {
457            let env = next(t).await;
458            let done = matches!(env.event, SessionEventWire::Conversation(_));
459            if matches!(env.event, SessionEventWire::Stream(_)) {
460                out.push(format!("{:?}", env.event));
461            }
462            if done {
463                break;
464            }
465        }
466        out
467    }
468
469    #[tokio::test]
470    async fn local_transport_round_trip_over_echo() {
471        let (handle, task) = SessionHandle::echo_for_test(SessionId::from("lt-1"));
472        let (mut t, snap) = LocalTransport::attach(handle, ClientMeta::new(ClientKind::Test))
473            .await
474            .unwrap();
475        assert_eq!(t.session_id().as_str(), "lt-1");
476        assert_eq!(t.client_id(), ClientId(1));
477        assert_eq!(t.mode(), AttachMode::Mirror);
478        assert!(t.input_owner().is_none());
479        assert!(matches!(t.reconnect(AttachMode::Mirror).await, Err(TransportError::Unsupported(_))));
480        assert_eq!(snap.meta.model, "echo");
481        assert_eq!(t.view().model, "echo");
482        assert!(snap.conversation.api_messages.is_empty());
483
484        // ClientJoined follows Attached on the shared channel.
485        let joined = next(&mut t).await;
486        assert!(matches!(
487            joined.event,
488            SessionEventWire::ClientJoined {
489                client: ClientId(1),
490                kind: ClientKind::Test,
491                ..
492            }
493        ));
494
495        t.send(SessionCommand::Submit {
496            text: "ping".into(),
497            attachments: vec![],
498        })
499        .await
500        .unwrap();
501
502        let mut seen = Vec::new();
503        loop {
504            let env = next(&mut t).await;
505            let done = matches!(env.event, SessionEventWire::Conversation(_));
506            seen.push(env);
507            if done {
508                break;
509            }
510        }
511        assert!(matches!(seen[0].event, SessionEventWire::TurnStarted { .. }));
512        match &seen[1].event {
513            SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::Text(t))) => assert_eq!(t, "ping"),
514            other => panic!("unexpected {other:?}"),
515        }
516        assert!(matches!(
517            seen[3].event,
518            SessionEventWire::Stream(StreamEvent::Session(SessionEvent::Done))
519        ));
520        let seqs: Vec<u64> = seen.iter().map(|e| e.seq).collect();
521        assert!(seqs.windows(2).all(|w| w[1] == w[0] + 1), "gapless: {seqs:?}");
522
523        // Query{View} answered with the published view.
524        t.send(SessionCommand::Query {
525            id: 7,
526            query: SessionQuery::View,
527        })
528        .await
529        .unwrap();
530        let q = next(&mut t).await;
531        match q.event {
532            SessionEventWire::QueryResult { id, value } => {
533                assert_eq!(id, 7);
534                assert_eq!(value["model"], "echo");
535            }
536            other => panic!("unexpected {other:?}"),
537        }
538
539        // Detach does not end the session; End does.
540        t.send(SessionCommand::Detach { client: t.client_id() })
541            .await
542            .unwrap();
543        let left = next(&mut t).await;
544        assert!(matches!(left.event, SessionEventWire::ClientLeft { client: ClientId(1) }));
545        assert!(t.handle().is_alive());
546
547        t.send(SessionCommand::End {
548            reason: EndReason::HostShutdown,
549        })
550        .await
551        .unwrap();
552        let ended = next(&mut t).await;
553        assert!(matches!(ended.event, SessionEventWire::Ended { .. }));
554        task.await.unwrap();
555        assert!(t.next_event().await.is_none());
556        assert!(matches!(
557            t.send(SessionCommand::Save).await,
558            Err(TransportError::Closed)
559        ));
560    }
561
562    #[tokio::test]
563    async fn two_local_clients_see_the_same_stream() {
564        let (handle, task) = SessionHandle::echo_for_test(SessionId::from("lt-2"));
565        let (mut a, _) = LocalTransport::attach(handle.clone(), ClientMeta::new(ClientKind::Tui))
566            .await
567            .unwrap();
568        let (mut b, snap_b) = LocalTransport::attach(handle.clone(), ClientMeta::new(ClientKind::Attach))
569            .await
570            .unwrap();
571        assert_ne!(a.client_id(), b.client_id());
572        assert_eq!(snap_b.clients.len(), 2);
573
574        a.send(SessionCommand::Submit {
575            text: "x".into(),
576            attachments: vec![],
577        })
578        .await
579        .unwrap();
580
581        let sa = collect(&mut a).await;
582        let sb = collect(&mut b).await;
583        assert_eq!(sa, sb);
584        assert_eq!(sa.len(), 3);
585
586        drop((a, b, handle));
587        task.await.unwrap();
588    }
589}