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