Skip to main content

agent_engine/session/
handle.rs

1//! `SessionHandle` — the in-process address of a session (PLAN-phase2 §2.6).
2//!
3//! `EngineHost::create_session` (A1) hands one out per session; every
4//! `ClientTransport` is built on it. `echo_for_test` ships a stand-in actor
5//! so package B can build the wire before the real `SessionActor` exists.
6
7use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
8use std::sync::Arc;
9
10use tokio::sync::{broadcast, mpsc};
11
12use super::transport::TransportError;
13use super::types::{Addressed, ClientId, Envelope, SessionCommand, SessionConfig, SessionId, SessionLifecycle, SessionMeta};
14
15/// Live client accounting published by the actor (B4 `sessions()` listing).
16#[derive(Clone, Debug, Default, PartialEq)]
17pub struct Presence {
18    pub clients: usize,
19    pub input_owner: Option<ClientId>,
20    pub awaiting_input: usize,
21    /// Every attached client, named (`synaps daemon sessions`).
22    pub roster: Vec<super::types::ClientInfo>,
23    /// Who may drive (`None` until the actor publishes).
24    pub client_input: Option<super::types::ClientInput>,
25    /// A turn or a compaction is running.
26    pub busy: bool,
27    /// What `state_since` dates (idle / busy / parked): a publish that
28    /// changes it restamps `state_since`.
29    pub state: PresenceState,
30    pub state_since: Option<chrono::DateTime<chrono::Utc>>,
31    /// Wall-clock idle park (Live, zero clients, grace armed).
32    pub park_due: Option<chrono::DateTime<chrono::Utc>>,
33    /// Wall-clock eviction of a Parked session.
34    pub evict_due: Option<chrono::DateTime<chrono::Utc>>,
35    /// Conversation title / cost / api message count. Kept as last seen
36    /// while Parked (no conversation in memory).
37    pub title: Option<String>,
38    pub cost_usd: f64,
39    pub messages: usize,
40}
41
42/// The coarse state `Presence::state_since` dates.
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
44pub enum PresenceState {
45    #[default]
46    Idle,
47    Busy,
48    Parked,
49}
50use super::view::RuntimeView;
51
52/// Bounded command queue depth (rpc `WRITER_CHAN_CAP` precedent).
53pub const CMD_CHAN_CAP: usize = 256;
54/// Default broadcast capacity; `SYNAPS_SESSION_EVENTS_CAP` overrides.
55pub const DEFAULT_EVENTS_CAP: usize = 1024;
56
57/// Broadcast capacity from `SYNAPS_SESSION_EVENTS_CAP` (min 16) or the default.
58pub fn events_cap() -> usize {
59    std::env::var("SYNAPS_SESSION_EVENTS_CAP")
60        .ok()
61        .and_then(|s| s.parse::<usize>().ok())
62        .map(|n| n.max(16))
63        .unwrap_or(DEFAULT_EVENTS_CAP)
64}
65
66#[derive(Clone)]
67pub struct SessionHandle {
68    pub id: SessionId,
69    cmd_tx: mpsc::Sender<Addressed>,
70    events: broadcast::Sender<Envelope>,
71    meta: Arc<SessionMeta>,
72    /// Sync, lock-free read of the last published RuntimeView (§2.7).
73    view: Arc<arc_swap::ArcSwap<RuntimeView>>,
74    /// Written by the actor (Live/Parking/Parked/Ending).
75    lifecycle: Arc<AtomicU8>,
76    /// `conv.session.id` — changes on LinkedSuccessor compaction.
77    journal_id: Arc<arc_swap::ArcSwap<String>>,
78    /// Clients / input owner / pending prompts, written by the actor.
79    presence: Arc<arc_swap::ArcSwap<Presence>>,
80    /// Session name — `--name` at create / `/cmd saveas` later. Written by
81    /// the actor so listings and `--continue <name>` see renames live.
82    name: Arc<arc_swap::ArcSwap<Option<String>>>,
83    /// The pin (`KeepWarm{on}`), written by the actor. Read without a probe
84    /// by listings and by the daemon's idle exit, which a pin holds.
85    keep_warm: Arc<AtomicBool>,
86    /// Per-session `/keepwarm` override (task #444), written by the actor.
87    /// Lives here so a daemon reload whose checkpoint did not confirm still
88    /// carries the user's explicit choice.
89    cache_keepwarm: super::cache_keepwarm::OverrideSlot,
90    /// The session's explicit `/memory` choice, written by the actor. Here
91    /// for the same reason as `cache_keepwarm`: a reload whose checkpoint
92    /// did not confirm must not re-grant memory the user turned off.
93    memory_mode: MemoryModeSlot,
94    /// F23: only on a lock-held placeholder — the config to retry the real
95    /// `create` with on the next attach. `None` for real actors.
96    placeholder_config: Option<Arc<SessionConfig>>,
97}
98
99impl std::fmt::Debug for SessionHandle {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        f.debug_struct("SessionHandle")
102            .field("id", &self.id)
103            .field("model", &self.meta.model)
104            .finish()
105    }
106}
107
108/// Actor-side ends of a session's channels, minted with the handle.
109pub struct SessionEndpoints {
110    pub cmd_rx: mpsc::Receiver<Addressed>,
111    pub events: broadcast::Sender<Envelope>,
112    pub view: Arc<arc_swap::ArcSwap<RuntimeView>>,
113    pub lifecycle: Arc<AtomicU8>,
114    pub journal_id: Arc<arc_swap::ArcSwap<String>>,
115    pub presence: Arc<arc_swap::ArcSwap<Presence>>,
116    pub name: Arc<arc_swap::ArcSwap<Option<String>>>,
117    pub keep_warm: Arc<AtomicBool>,
118    pub cache_keepwarm: super::cache_keepwarm::OverrideSlot,
119    pub memory_mode: MemoryModeSlot,
120}
121
122/// The session's explicit `/memory` choice, readable without the actor.
123pub type MemoryModeSlot = Arc<std::sync::Mutex<Option<super::SessionMemoryMode>>>;
124
125impl SessionHandle {
126    /// Mint a handle + the actor's ends. Used by `SessionActor` (A1) and the
127    /// test `EchoActor`.
128    pub fn new(meta: SessionMeta, view: RuntimeView) -> (Self, SessionEndpoints) {
129        let (cmd_tx, cmd_rx) = mpsc::channel(CMD_CHAN_CAP);
130        let (events, _) = broadcast::channel(events_cap());
131        let view = Arc::new(arc_swap::ArcSwap::from_pointee(view));
132        let lifecycle = Arc::new(AtomicU8::new(SessionLifecycle::Live as u8));
133        let journal_id = Arc::new(arc_swap::ArcSwap::from_pointee(meta.id.0.clone()));
134        let presence = Arc::new(arc_swap::ArcSwap::from_pointee(Presence::default()));
135        let name = Arc::new(arc_swap::ArcSwap::from_pointee(meta.name.clone()));
136        let keep_warm = Arc::new(AtomicBool::new(meta.pinned));
137        let cache_keepwarm = super::cache_keepwarm::OverrideSlot::default();
138        let memory_mode = MemoryModeSlot::default();
139        let handle = Self {
140            id: meta.id.clone(),
141            cmd_tx,
142            events: events.clone(),
143            meta: Arc::new(meta),
144            view: Arc::clone(&view),
145            lifecycle: Arc::clone(&lifecycle),
146            journal_id: Arc::clone(&journal_id),
147            presence: Arc::clone(&presence),
148            name: Arc::clone(&name),
149            keep_warm: Arc::clone(&keep_warm),
150            cache_keepwarm: Arc::clone(&cache_keepwarm),
151            memory_mode: Arc::clone(&memory_mode),
152            placeholder_config: None,
153        };
154        (
155            handle,
156            SessionEndpoints {
157                cmd_rx,
158                events,
159                view,
160                lifecycle,
161                journal_id,
162                presence,
163                name,
164                keep_warm,
165                cache_keepwarm,
166                memory_mode,
167            },
168        )
169    }
170
171    /// Send a host-originated command (`from: None`). `Backpressure` when
172    /// the queue is full (never drop silently); `Closed` once the actor is
173    /// gone.
174    pub async fn send(&self, cmd: SessionCommand) -> Result<(), TransportError> {
175        self.send_addressed(Addressed { from: None, cmd })
176    }
177
178    /// Send on behalf of an attached client (ownership is enforced by the
179    /// actor, B1).
180    pub async fn send_from(&self, from: ClientId, cmd: SessionCommand) -> Result<(), TransportError> {
181        self.send_addressed(Addressed { from: Some(from), cmd })
182    }
183
184    fn send_addressed(&self, cmd: Addressed) -> Result<(), TransportError> {
185        match self.cmd_tx.try_send(cmd) {
186            Ok(()) => Ok(()),
187            Err(mpsc::error::TrySendError::Full(_)) => Err(TransportError::Backpressure),
188            Err(mpsc::error::TrySendError::Closed(_)) => Err(TransportError::Closed),
189        }
190    }
191
192    /// Park/unpark state as last written by the actor.
193    pub fn lifecycle(&self) -> SessionLifecycle {
194        SessionLifecycle::from_u8(self.lifecycle.load(Ordering::Acquire))
195    }
196
197    /// Current `conv.session.id` (= `id` until a LinkedSuccessor compaction).
198    pub fn journal_id(&self) -> String {
199        (**self.journal_id.load()).clone()
200    }
201
202    /// Clients / input owner / pending prompts as last published.
203    pub fn presence(&self) -> Presence {
204        (**self.presence.load()).clone()
205    }
206
207    /// Current session name (create `--name` or a later `saveas`).
208    pub fn name(&self) -> Option<String> {
209        (**self.name.load()).clone()
210    }
211
212    /// Whether the session is pinned (`KeepWarm{on}`), as last written by
213    /// the actor.
214    pub fn pinned(&self) -> bool {
215        self.keep_warm.load(Ordering::Acquire)
216    }
217
218    /// The session's `/keepwarm` override (`off`/`auto`/`<N>h`), if any.
219    pub fn cache_keepwarm_override(&self) -> Option<String> {
220        self.cache_keepwarm
221            .lock()
222            .unwrap_or_else(std::sync::PoisonError::into_inner)
223            .map(|m| m.to_string())
224    }
225
226    /// The session's explicit `/memory` choice, if it made one.
227    pub fn memory_mode(&self) -> Option<super::SessionMemoryMode> {
228        *self
229            .memory_mode
230            .lock()
231            .unwrap_or_else(std::sync::PoisonError::into_inner)
232    }
233
234    /// `meta()` with the live cells filled in (lifecycle, clients, owner,
235    /// awaiting_input, client_input, model, journal_id, name, pinned, and the
236    /// presence's busy/state/deadlines/title/cost/messages) — what listings show.
237    pub fn meta_live(&self) -> SessionMeta {
238        let mut m = (*self.meta).clone();
239        let p = self.presence();
240        m.lifecycle = self.lifecycle();
241        m.clients = p.clients;
242        m.input_owner = p.input_owner;
243        m.awaiting_input = p.awaiting_input;
244        m.roster = p.roster;
245        m.client_input = p.client_input;
246        // `/model` from any client changes it; the create-time meta doesn't.
247        m.model = self.view.load().model.clone();
248        m.journal_id = self.journal_id();
249        m.name = self.name();
250        m.pinned = self.pinned();
251        m.busy = p.busy;
252        m.state_since = p.state_since;
253        m.park_due = p.park_due;
254        m.evict_due = p.evict_due;
255        m.title = p.title;
256        m.cost_usd = p.cost_usd;
257        m.messages = p.messages;
258        m
259    }
260
261    pub fn subscribe(&self) -> broadcast::Receiver<Envelope> {
262        self.events.subscribe()
263    }
264
265    /// F23: the retry config carried by a lock-held placeholder.
266    pub fn placeholder_config(&self) -> Option<&SessionConfig> {
267        self.placeholder_config.as_deref()
268    }
269
270    pub(crate) fn with_placeholder_config(mut self, cfg: SessionConfig) -> Self {
271        self.placeholder_config = Some(Arc::new(cfg));
272        self
273    }
274
275    pub fn meta(&self) -> &SessionMeta {
276        &self.meta
277    }
278
279    pub fn view(&self) -> arc_swap::Guard<Arc<RuntimeView>> {
280        self.view.load()
281    }
282
283    /// Whether the actor is still alive (its command receiver exists).
284    pub fn is_alive(&self) -> bool {
285        !self.cmd_tx.is_closed()
286    }
287
288    /// Resolves once the actor is gone (command receiver dropped).
289    pub async fn closed(&self) {
290        self.cmd_tx.closed().await
291    }
292
293    /// EchoActor: `Submit{text}` → `TurnStarted`, `Stream(Text(text))`,
294    /// `Stream(Done)`, `Conversation` — for B's socket tests before A1 lands.
295    #[cfg(any(test, feature = "testing"))]
296    pub fn echo_for_test(id: SessionId) -> (Self, tokio::task::JoinHandle<()>) {
297        echo::spawn(id)
298    }
299}
300
301#[cfg(any(test, feature = "testing"))]
302pub mod echo {
303    //! Minimal stand-in actor. Honours the envelope/seq contract, `Attach`,
304    //! `Detach`, `Submit`, `Steer`, `Cancel`, `Query{View}`, `End`;
305    //! everything else is acknowledged with a `SystemNotice`.
306
307    use std::collections::HashMap;
308
309    use agent_core::reasoning::ReasoningLevel;
310
311    use super::*;
312    use crate::session::types::*;
313    use crate::{LlmEvent, SessionEvent, StreamEvent};
314
315    pub fn meta_for(id: &SessionId) -> SessionMeta {
316        SessionMeta {
317            id: id.clone(),
318            name: None,
319            model: "echo".into(),
320            cwd: None,
321            created_at: chrono::Utc::now(),
322            continued: false,
323            continue_info: None,
324            host_pid: std::process::id(),
325            lifecycle: SessionLifecycle::Live,
326            clients: 0,
327            input_owner: None,
328            awaiting_input: 0,
329            roster: Vec::new(),
330            client_input: None,
331            journal_id: id.0.clone(),
332            locked_by: None,
333            pinned: false,
334            busy: false,
335            state_since: None,
336            park_due: None,
337            evict_due: None,
338            title: None,
339            cost_usd: 0.0,
340            messages: 0,
341        }
342    }
343
344    pub fn view_for() -> RuntimeView {
345        RuntimeView {
346            model: "echo".into(),
347            thinking_level: "off".into(),
348            reasoning_level: ReasoningLevel::Off,
349            is_reasoning_explicit: false,
350            thinking_budget: 0,
351            context_window: 200_000,
352            system_prompt: None,
353            compaction_model: "echo".into(),
354            api_retries: 0,
355            subagent_timeout: 0,
356            max_tool_output: 4096,
357            bash_timeout: 30,
358            bash_max_timeout: 300,
359            prompt_generation: 0,
360            hook_handler_count: 0,
361            prompt_inspection: None,
362        }
363    }
364
365    pub fn spawn(id: SessionId) -> (SessionHandle, tokio::task::JoinHandle<()>) {
366        let (handle, ep) = SessionHandle::new(meta_for(&id), view_for());
367        let task = tokio::spawn(run(id, ep));
368        (handle, task)
369    }
370
371    struct Echo {
372        id: SessionId,
373        events: broadcast::Sender<Envelope>,
374        view: Arc<arc_swap::ArcSwap<RuntimeView>>,
375        seq: u64,
376        next_client: u64,
377        clients: HashMap<ClientId, ClientMeta>,
378        conv: ConversationSnapshot,
379    }
380
381    impl Echo {
382        fn emit(&mut self, event: SessionEventWire) {
383            let env = Envelope {
384                session_id: self.id.clone(),
385                seq: self.seq,
386                ts: chrono::Utc::now(),
387                event,
388            };
389            self.seq += 1;
390            // No receivers is not an error: streams are not tied to clients.
391            let _ = self.events.send(env);
392        }
393
394        fn conv_snapshot(&self) -> ConversationSnapshot {
395            let mut c = self.conv.clone();
396            c.messages_len = c.api_messages.len();
397            c
398        }
399
400        /// Mirrors `SessionActor::snapshot_for`: Digest clients get the
401        /// tail, not the history.
402        fn snapshot_for(&self, client: &ClientMeta) -> AttachSnapshot {
403            let mut conversation = self.conv_snapshot();
404            let display_tail = (client.history == HistoryMode::Digest).then(|| {
405                let t = crate::session::display::display_tail(&conversation.api_messages, client.tail_items);
406                conversation.api_messages = Vec::new();
407                t
408            });
409            AttachSnapshot {
410                meta: meta_for(&self.id),
411                view: (**self.view.load()).clone(),
412                conversation,
413                streaming: false,
414                replay: Vec::new(),
415                pending_prompts: Vec::new(),
416                clients: self.clients.iter().map(|(c, m)| (*c, m.kind)).collect(),
417                roster: Vec::new(),
418                client_input: ClientInput::wire_default(),
419                input_owner: None,
420                display_tail,
421            }
422        }
423
424        fn handle(&mut self, cmd: SessionCommand) -> bool {
425            match cmd {
426                SessionCommand::Attach { client, mode } => {
427                    let cid = ClientId(self.next_client);
428                    self.next_client += 1;
429                    let kind = client.kind;
430                    let snapshot_meta = client.clone();
431                    self.clients.insert(cid, client);
432                    if mode != AttachMode::Mirror {
433                        self.emit(SessionEventWire::SystemNotice(format!(
434                            "attach mode {mode:?} not supported yet; using mirror"
435                        )));
436                    }
437                    let snapshot = self.snapshot_for(&snapshot_meta);
438                    self.emit(SessionEventWire::Attached {
439                        client: cid,
440                        snapshot,
441                    });
442                    let name = kind.default_name().to_string();
443                    self.emit(SessionEventWire::ClientJoined { client: cid, kind, name, mode: AttachMode::Mirror });
444                }
445                SessionCommand::Detach { client } => {
446                    if self.clients.remove(&client).is_some() {
447                        self.emit(SessionEventWire::ClientLeft { client });
448                    }
449                }
450                SessionCommand::Submit { text, .. } => {
451                    let baseline = self.conv.api_messages.len();
452                    self.conv.api_messages.push(Arc::new(serde_json::json!({
453                        "role": "user", "content": text
454                    })));
455                    self.emit(SessionEventWire::TurnStarted {
456                        turn_baseline: baseline,
457                        trigger: TurnTrigger::User,
458                        user_text: None,
459                    });
460                    self.emit(SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::Text(
461                        text.clone(),
462                    ))));
463                    self.conv.api_messages.push(Arc::new(serde_json::json!({
464                        "role": "assistant", "content": text
465                    })));
466                    self.emit(SessionEventWire::Stream(StreamEvent::Session(
467                        SessionEvent::MessageHistory(self.conv.api_messages.clone()),
468                    )));
469                    self.emit(SessionEventWire::Stream(StreamEvent::Session(
470                        SessionEvent::Done,
471                    )));
472                    let snap = self.conv_snapshot();
473                    self.emit(SessionEventWire::Conversation(snap));
474                }
475                SessionCommand::Steer { text } => {
476                    self.conv.queued_message = Some(text.clone());
477                    self.emit(SessionEventWire::Steered {
478                        text,
479                        delivered: false,
480                    });
481                }
482                SessionCommand::Cancel => {
483                    let snap = self.conv_snapshot();
484                    self.emit(SessionEventWire::Conversation(snap));
485                }
486                SessionCommand::Query { id, query } => {
487                    let value = match query {
488                        SessionQuery::View => {
489                            serde_json::to_value(&**self.view.load()).unwrap_or_default()
490                        }
491                        SessionQuery::Messages => {
492                            serde_json::to_value(&self.conv.api_messages).unwrap_or_default()
493                        }
494                        SessionQuery::DisplayTail { items } => serde_json::to_value(
495                            super::super::display::display_tail(&self.conv.api_messages, items),
496                        )
497                        .unwrap_or_default(),
498                        other => serde_json::json!({ "unsupported": format!("{other:?}") }),
499                    };
500                    self.emit(SessionEventWire::QueryResult { id, value });
501                }
502                SessionCommand::Set { id, setting } => {
503                    let mut view = (**self.view.load()).clone();
504                    if let SessionSetting::Model { model } = &setting {
505                        view.model = model.clone();
506                    }
507                    self.view.store(Arc::new(view.clone()));
508                    self.emit(SessionEventWire::SettingChanged(SettingApplied {
509                        id,
510                        setting: "echo".into(),
511                        ok: true,
512                        message: None,
513                        view,
514                        clamp: None,
515                    }));
516                }
517                SessionCommand::End { reason } => {
518                    self.emit(SessionEventWire::Ended { reason });
519                    return false;
520                }
521                other => {
522                    self.emit(SessionEventWire::SystemNotice(format!("echo: {other:?}")));
523                }
524            }
525            true
526        }
527    }
528
529    async fn run(id: SessionId, mut ep: SessionEndpoints) {
530        let mut echo = Echo {
531            id,
532            events: ep.events,
533            view: ep.view,
534            seq: 0,
535            next_client: 1,
536            clients: HashMap::new(),
537            conv: ConversationSnapshot::default(),
538        };
539        while let Some(Addressed { cmd, .. }) = ep.cmd_rx.recv().await {
540            if !echo.handle(cmd) {
541                break;
542            }
543        }
544    }
545}
546
547/// F23: a Parked placeholder for sessions whose journal lock is held by
548/// another process at reload time. Refuses Attach while held; retries
549/// on every Attach so that once the holder exits, the next attach works.
550pub mod locked_placeholder {
551    use std::sync::atomic::Ordering;
552
553    use super::*;
554    use crate::session::types::*;
555    
556
557    /// Spawn a placeholder actor for session `id` whose journal is locked
558    /// by `holder_desc` (e.g. `"pid 1234 (tui)"`). The handle starts
559    /// Parked; Attach emits `AttachRefused` naming the holder. The caller
560    /// registers it in the session map under the SAME id — no alias.
561    pub fn spawn(
562        id: SessionId,
563        config: SessionConfig,
564        holder_desc: String,
565    ) -> (SessionHandle, tokio::task::JoinHandle<()>) {
566        let meta = SessionMeta {
567            id: id.clone(),
568            name: config.name.clone(),
569            model: config.model_override.clone().unwrap_or_else(|| "unknown".into()),
570            cwd: config.cwd.clone(),
571            created_at: chrono::Utc::now(),
572            continued: true,
573            continue_info: None,
574            host_pid: std::process::id(),
575            lifecycle: SessionLifecycle::Parked,
576            clients: 0,
577            input_owner: None,
578            awaiting_input: 0,
579            roster: Vec::new(),
580            client_input: None,
581            journal_id: id.0.clone(),
582            locked_by: Some(holder_desc.clone()),
583            pinned: false,
584            busy: false,
585            state_since: None,
586            park_due: None,
587            evict_due: None,
588            title: None,
589            cost_usd: 0.0,
590            messages: 0,
591        };
592        let view = crate::session::view::RuntimeView {
593            model: config.model_override.clone().unwrap_or_else(|| "unknown".into()),
594            thinking_level: "off".into(),
595            reasoning_level: agent_core::reasoning::ReasoningLevel::Off,
596            is_reasoning_explicit: false,
597            thinking_budget: 0,
598            context_window: 200_000,
599            system_prompt: None,
600            compaction_model: "unknown".into(),
601            api_retries: 0,
602            subagent_timeout: 0,
603            max_tool_output: 4096,
604            bash_timeout: 30,
605            bash_max_timeout: 300,
606            prompt_generation: 0,
607            hook_handler_count: 0,
608            prompt_inspection: None,
609        };
610        let (handle, ep) = SessionHandle::new(meta, view);
611        // It never answers a checkpoint: a reload records it from the
612        // handle, which must carry its memory choice like a live actor's.
613        *ep.memory_mode
614            .lock()
615            .unwrap_or_else(std::sync::PoisonError::into_inner) = config.memory_mode;
616        let handle = handle.with_placeholder_config(config.clone());
617        // Start as Parked.
618        ep.lifecycle.store(SessionLifecycle::Parked as u8, Ordering::Release);
619        let task = tokio::spawn(run(id, ep, config, holder_desc));
620        (handle, task)
621    }
622
623    async fn run(
624        id: SessionId,
625        mut ep: SessionEndpoints,
626        _config: SessionConfig,
627        holder_desc: String,
628    ) {
629        let mut seq: u64 = 0;
630        let emit = |seq: &mut u64, events: &broadcast::Sender<Envelope>, event: SessionEventWire| {
631            let env = Envelope {
632                session_id: id.clone(),
633                seq: *seq,
634                ts: chrono::Utc::now(),
635                event,
636            };
637            *seq += 1;
638            let _ = events.send(env);
639        };
640
641        while let Some(Addressed { cmd, .. }) = ep.cmd_rx.recv().await {
642            match cmd {
643                SessionCommand::Attach { .. } => {
644                    let msg = format!(
645                        "session {}: journal locked by {} \
646                         — close it or use synaps --attach from that process",
647                        id.as_str(),
648                        holder_desc
649                    );
650                    emit(&mut seq, &ep.events, SessionEventWire::AttachRefused { message: msg });
651                }
652                SessionCommand::Park => {
653                    // Already parked, no-op.
654                }
655                SessionCommand::End { reason } => {
656                    emit(&mut seq, &ep.events, SessionEventWire::Ended { reason });
657                    break;
658                }
659                SessionCommand::Detach { client } => {
660                    emit(&mut seq, &ep.events, SessionEventWire::ClientLeft { client });
661                }
662                SessionCommand::Query { id: qid, .. } => {
663                    emit(
664                        &mut seq,
665                        &ep.events,
666                        SessionEventWire::QueryResult {
667                            id: qid,
668                            value: serde_json::json!({
669                                "locked_by": holder_desc,
670                                "streaming": false,
671                                "pending_prompts": 0,
672                            }),
673                        },
674                    );
675                }
676                _ => {
677                    emit(
678                        &mut seq,
679                        &ep.events,
680                        SessionEventWire::SystemNotice(format!(
681                            "session {}: journal locked by {} — command ignored",
682                            id.as_str(),
683                            holder_desc
684                        )),
685                    );
686                }
687            }
688        }
689    }
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695    use crate::session::types::*;
696    use crate::{LlmEvent, SessionEvent, StreamEvent};
697
698    async fn next(rx: &mut broadcast::Receiver<Envelope>) -> Envelope {
699        tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
700            .await
701            .expect("timely")
702            .expect("open")
703    }
704
705    #[tokio::test]
706    async fn echo_actor_submit_emits_turn_in_order() {
707        let (handle, task) = SessionHandle::echo_for_test(SessionId::from("echo-1"));
708        let mut rx = handle.subscribe();
709        handle
710            .send(SessionCommand::Submit {
711                text: "hello".into(),
712                attachments: vec![],
713            })
714            .await
715            .unwrap();
716
717        let e0 = next(&mut rx).await;
718        assert!(matches!(
719            e0.event,
720            SessionEventWire::TurnStarted {
721                turn_baseline: 0,
722                trigger: TurnTrigger::User,
723                user_text: None,
724            }
725        ));
726        let e1 = next(&mut rx).await;
727        match e1.event {
728            SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::Text(t))) => assert_eq!(t, "hello"),
729            other => panic!("unexpected {other:?}"),
730        }
731        let e2 = next(&mut rx).await;
732        assert!(matches!(
733            e2.event,
734            SessionEventWire::Stream(StreamEvent::Session(SessionEvent::MessageHistory(_)))
735        ));
736        let e3 = next(&mut rx).await;
737        assert!(matches!(
738            e3.event,
739            SessionEventWire::Stream(StreamEvent::Session(SessionEvent::Done))
740        ));
741        let e4 = next(&mut rx).await;
742        match e4.event {
743            SessionEventWire::Conversation(c) => assert_eq!(c.api_messages.len(), 2),
744            other => panic!("unexpected {other:?}"),
745        }
746        // gapless seq, same session id on every envelope
747        assert_eq!(
748            [e0.seq, e1.seq, e2.seq, e3.seq, e4.seq],
749            [0, 1, 2, 3, 4]
750        );
751        assert!(e4.session_id == SessionId::from("echo-1"));
752
753        handle
754            .send(SessionCommand::End {
755                reason: EndReason::ClientQuit,
756            })
757            .await
758            .unwrap();
759        let e5 = next(&mut rx).await;
760        assert!(matches!(
761            e5.event,
762            SessionEventWire::Ended {
763                reason: EndReason::ClientQuit
764            }
765        ));
766        task.await.unwrap();
767        assert!(!handle.is_alive());
768        assert!(matches!(
769            handle.send(SessionCommand::Save).await,
770            Err(TransportError::Closed)
771        ));
772    }
773
774    #[tokio::test]
775    async fn handle_view_swaps_on_set() {
776        let (handle, task) = SessionHandle::echo_for_test(SessionId::from("echo-2"));
777        let mut rx = handle.subscribe();
778        assert_eq!(handle.view().model, "echo");
779        handle
780            .send(SessionCommand::Set {
781                id: 0,
782                setting: SessionSetting::Model { model: "other".into() },
783            })
784            .await
785            .unwrap();
786        let e = next(&mut rx).await;
787        assert!(matches!(e.event, SessionEventWire::SettingChanged(_)));
788        assert_eq!(handle.view().model, "other");
789        drop(handle);
790        task.await.unwrap();
791    }
792}