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