Skip to main content

aion_server/assistant/sessions/
registry.rs

1//! The registry: every assistant session this server knows about, live or not.
2//!
3//! # Two halves, one answer
4//!
5//! The DURABLE half is the store: a record per session and a transcript per
6//! session, both surviving any number of restarts. The LIVE half is a map of
7//! harness processes, which does not survive one. A session's state is the join
8//! of the two, projected — never a field either half stores.
9//!
10//! # The boot sweep writes back
11//!
12//! A record whose process is gone is settled at boot by an APPENDED record
13//! saying so, with its cause: `dormant` when the agent advertised `loadSession`
14//! (its own storage can reopen the conversation) and `ended` when it did not.
15//! Written back rather than merely displayed, because a listing that showed
16//! "ended" while the record said nothing would be a projection with no durable
17//! answer behind it — and because the resume decision has to be readable by
18//! whoever asks next, not recomputed identically in three places.
19
20use std::sync::Arc;
21
22use aion_core::{
23    AssistantSessionEvent, AssistantSessionFrame, AssistantSessionId, AssistantSessionProjection,
24    AssistantSessionState, AssistantSessionSummary, AssistantTurnContext, TITLE_CHARACTERS,
25};
26use aion_integration_acp::catalogue::CatalogueHarness;
27use aion_store::assistant::{
28    AssistantSessionRecord, AssistantSessionStore, AssistantTranscriptEvent,
29};
30use chrono::Utc;
31use dashmap::DashMap;
32use tokio::sync::broadcast;
33
34use crate::config::ResolvedAssistantConfig;
35
36use super::error::AssistantSessionError;
37use super::launch::AssistantEndpoints;
38use super::live::{LiveSession, Recorder};
39
40/// Every assistant session this server knows about.
41#[derive(Clone)]
42pub struct AssistantSessions {
43    inner: Arc<Inner>,
44}
45
46struct Inner {
47    live: DashMap<AssistantSessionId, Arc<LiveSession>>,
48    /// One lock per session, serialising SPAWNS on it.
49    ///
50    /// Per session rather than one lock for the registry: a spawn waits on an
51    /// ACP handshake, and one session's slow agent must not hold up another
52    /// operator's first turn. Entries are kept for the life of the registry —
53    /// a mutex is a word, and the alternative is a removal that could race the
54    /// acquisition it is meant to protect.
55    spawn_locks: DashMap<AssistantSessionId, Arc<tokio::sync::Mutex<()>>>,
56    /// One live-frame channel per session, INDEPENDENT of whether a process is
57    /// running.
58    ///
59    /// 🔴 The channel cannot belong to the process. A client that opens the
60    /// socket on a dormant session and then sends a turn must see that turn's
61    /// frames: if the channel were minted with the harness, the subscriber
62    /// taken before the spawn would be listening to a channel nothing would
63    /// ever publish to, and the socket would sit silent through a whole
64    /// conversation with no error to show for it.
65    channels: DashMap<AssistantSessionId, broadcast::Sender<AssistantSessionFrame>>,
66    /// Per-session locks serializing document-edit validate-then-append
67    /// (`sessions/document_edits.rs`), minted on first use like the channels.
68    document_edits: DashMap<AssistantSessionId, Arc<tokio::sync::Mutex<()>>>,
69    store: Arc<dyn AssistantSessionStore>,
70    config: ResolvedAssistantConfig,
71    endpoint: Option<AssistantEndpoints>,
72    /// The harnesses this registry can name and launch. The server passes the
73    /// shipped catalogue; a test passes one whose programs it controls, so a
74    /// cell about what happens AFTER a spawn is attempted never has a host
75    /// launcher (`npx`, node) on its clock.
76    catalogue: &'static [CatalogueHarness],
77    /// The store failure that makes this surface unavailable, once one has been
78    /// observed. A `RwLock` because it is read on every description and written
79    /// at most twice in a server's life.
80    store_fault: std::sync::RwLock<Option<String>>,
81}
82
83impl AssistantSessions {
84    /// Build the registry over one durable store and the operator's
85    /// configuration.
86    #[must_use]
87    pub fn new(
88        store: Arc<dyn AssistantSessionStore>,
89        config: ResolvedAssistantConfig,
90        endpoint: Option<AssistantEndpoints>,
91        catalogue: &'static [CatalogueHarness],
92    ) -> Self {
93        Self {
94            inner: Arc::new(Inner {
95                live: DashMap::new(),
96                document_edits: DashMap::new(),
97                spawn_locks: DashMap::new(),
98                channels: DashMap::new(),
99                store,
100                config,
101                endpoint,
102                catalogue,
103                store_fault: std::sync::RwLock::new(None),
104            }),
105        }
106    }
107
108    /// The harnesses this registry resolves names through.
109    pub(crate) fn catalogue(&self) -> &'static [CatalogueHarness] {
110        self.inner.catalogue
111    }
112
113    /// The operator's resolved `[assistant]` configuration.
114    #[must_use]
115    pub fn config(&self) -> &ResolvedAssistantConfig {
116        &self.inner.config
117    }
118
119    /// Whether this server can open a session at all, and why not when it
120    /// cannot.
121    ///
122    /// A stock server can: there is no `[assistant]` section to write, the
123    /// harness catalogue ships with the build, and "not configured" is no longer
124    /// a reason anything may give (RULED 2026-08-29). What CAN take the surface
125    /// down is the durable store the sessions live in — a session is a record
126    /// and a transcript before it is a process — and that is a refusal the
127    /// product can name, with the store's own error in it.
128    ///
129    /// Read from the boot sweep, which is the one place this server has already
130    /// exercised the store end to end. Nothing probes on the descriptor path: a
131    /// full listing per description would make the panel's own refresh the
132    /// heaviest read on the box.
133    ///
134    /// Whether a PARTICULAR harness can run is a different question with a
135    /// different answer per entry, and it is answered on the descriptor's
136    /// `harnesses[]` (`available`, with the install hint) rather than folded
137    /// into one sentence here.
138    #[must_use]
139    pub fn availability(&self) -> Availability {
140        match self.store_fault() {
141            Some(reason) => Availability::Unavailable { reason },
142            None => Availability::Available,
143        }
144    }
145
146    /// The store failure the boot sweep observed, if it observed one.
147    fn store_fault(&self) -> Option<String> {
148        self.inner
149            .store_fault
150            .read()
151            .unwrap_or_else(std::sync::PoisonError::into_inner)
152            .clone()
153    }
154
155    /// Record that the durable store could not be read or written.
156    ///
157    /// Called by the boot sweep, which is the first thing this server does with
158    /// the assistant store. It is what turns `sessions_disabled_reason` from a
159    /// field nothing ever fills into the product's own sentence about a store it
160    /// cannot use.
161    pub(crate) fn report_store_fault(&self, error: &AssistantSessionError) {
162        let reason = format!("{STORE_UNUSABLE}: {error}");
163        tracing::error!(%reason, "assistant sessions are unavailable on this server");
164        *self
165            .inner
166            .store_fault
167            .write()
168            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(reason);
169    }
170
171    /// Clear a previously reported store fault, the store having answered.
172    pub(crate) fn clear_store_fault(&self) {
173        self.inner
174            .store_fault
175            .write()
176            .unwrap_or_else(std::sync::PoisonError::into_inner)
177            .take();
178    }
179
180    /// The harness this caller last opened a session on, or [`None`].
181    ///
182    /// Read from the store, so it survives the restart that a remembered
183    /// in-process choice would not. [`None`] is a complete answer — a caller who
184    /// has picked nothing has picked nothing — and the console preselects the
185    /// first available catalogue entry rather than the server inventing one.
186    ///
187    /// # Errors
188    ///
189    /// Whatever the store reports.
190    pub async fn last_harness_pick(
191        &self,
192        subject: &str,
193    ) -> Result<Option<String>, AssistantSessionError> {
194        Ok(self.inner.store.assistant_default_harness(subject).await?)
195    }
196
197    /// Whether a session's agent is handed this server's own assistant tool
198    /// server — the `assistant_context` route.
199    ///
200    /// Independent of `[mcp] enabled` and of `[assistant.tools] aion`: the only
201    /// thing that can take it away is this server being unable to state an
202    /// address an agent could dial back on.
203    #[must_use]
204    pub fn hands_over_assistant_tools(&self) -> bool {
205        self.inner.endpoint.is_some()
206    }
207
208    /// Whether a session's agent is handed this server's GENERAL MCP endpoint —
209    /// the workflow tools.
210    ///
211    /// One switch, `[mcp] enabled`, and no second one: the `[assistant.tools]
212    /// aion` knob was retired with the rest of the section. Whether this server
213    /// publishes workflow tools at all is a question an operator answers once,
214    /// where the tools are; asking it again under the assistant would be a
215    /// second thing to keep in step, and a session whose agent silently lacked
216    /// the tools the server publishes is exactly the confusion that costs.
217    #[must_use]
218    pub fn hands_over_general_mcp(&self) -> bool {
219        self.inner
220            .endpoint
221            .as_ref()
222            .and_then(AssistantEndpoints::aion_mcp_url)
223            .is_some()
224    }
225
226    /// This server's own MCP endpoint, when it has one to hand an agent.
227    pub(crate) fn aion_endpoint(&self) -> Option<&AssistantEndpoints> {
228        self.inner.endpoint.as_ref()
229    }
230
231    /// The lock serialising spawns for one session.
232    pub(crate) fn spawn_lock(&self, session_id: AssistantSessionId) -> Arc<tokio::sync::Mutex<()>> {
233        Arc::clone(
234            self.inner
235                .spawn_locks
236                .entry(session_id)
237                .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
238                .value(),
239        )
240    }
241
242    /// Every session this server currently holds a process for.
243    pub(crate) fn live_ids(&self) -> Vec<AssistantSessionId> {
244        self.inner.live.iter().map(|entry| *entry.key()).collect()
245    }
246
247    /// The durable store behind this registry.
248    pub(crate) fn store(&self) -> &Arc<dyn AssistantSessionStore> {
249        &self.inner.store
250    }
251
252    /// The per-session document-edit lock map (`sessions/document_edits.rs`).
253    pub(super) fn document_edit_locks(
254        &self,
255    ) -> &DashMap<AssistantSessionId, Arc<tokio::sync::Mutex<()>>> {
256        &self.inner.document_edits
257    }
258
259    /// The live process for a session, if this server is holding one.
260    pub(crate) fn live(&self, session_id: AssistantSessionId) -> Option<Arc<LiveSession>> {
261        self.inner
262            .live
263            .get(&session_id)
264            .map(|entry| Arc::clone(entry.value()))
265    }
266
267    /// Adopt a started harness process for a session.
268    pub(crate) fn adopt(&self, session: Arc<LiveSession>) {
269        self.inner.live.insert(session.session_id(), session);
270    }
271
272    /// Forget a session's process, returning it so a caller can close it.
273    pub(crate) fn forget(&self, session_id: AssistantSessionId) -> Option<Arc<LiveSession>> {
274        self.inner
275            .live
276            .remove(&session_id)
277            .map(|(_id, session)| session)
278    }
279
280    /// A recorder for a session, live or not.
281    ///
282    /// A session with no process still records: the boot sweep settles one by
283    /// APPENDING to its transcript, and a settlement that could only be written
284    /// while a process was running would be a settlement that could never be
285    /// written at all.
286    ///
287    /// Every recorder for one session publishes to the SAME channel — the one
288    /// this registry holds, not one the harness minted — so a subscriber taken
289    /// before a spawn still receives what the spawn goes on to produce.
290    pub(crate) fn recorder(&self, session_id: AssistantSessionId) -> Recorder {
291        Recorder::new(
292            session_id,
293            Arc::clone(&self.inner.store),
294            self.channel(session_id),
295        )
296    }
297
298    /// The live-frame channel for a session, minted on first use.
299    fn channel(&self, session_id: AssistantSessionId) -> broadcast::Sender<AssistantSessionFrame> {
300        self.inner
301            .channels
302            .entry(session_id)
303            .or_insert_with(|| {
304                let (sender, _receiver) = broadcast::channel(LIVE_FRAME_BUFFER);
305                sender
306            })
307            .value()
308            .clone()
309    }
310
311    /// The record for a session the caller owns.
312    ///
313    /// # Errors
314    ///
315    /// [`AssistantSessionError::NotFound`] when no record exists, and
316    /// [`AssistantSessionError::NotYours`] when one exists under another
317    /// subject — reported to the caller as the same not-found, so nobody learns
318    /// that another subject's session exists.
319    pub(crate) async fn owned_record(
320        &self,
321        subject: &str,
322        session_id: AssistantSessionId,
323    ) -> Result<AssistantSessionRecord, AssistantSessionError> {
324        let record = self
325            .inner
326            .store
327            .get_assistant_session(&session_id)
328            .await?
329            .ok_or(AssistantSessionError::NotFound { session_id })?;
330        if record.subject != subject {
331            return Err(AssistantSessionError::NotYours {
332                session_id,
333                subject: subject.to_owned(),
334            });
335        }
336        Ok(record)
337    }
338
339    /// One session's record, with NO caller narrowing.
340    ///
341    /// The narrowed read ([`Self::owned_record`]) is for a human caller, whose
342    /// authority is a subject. The assistant MCP route's caller is a SESSION —
343    /// it holds that session's own minted bearer and no subject at all — so it
344    /// reads the record it is about to prove it is, and the proof is the digest
345    /// comparison rather than a subject match.
346    ///
347    /// # Errors
348    ///
349    /// Whatever the store reports.
350    pub async fn record(
351        &self,
352        session_id: AssistantSessionId,
353    ) -> Result<Option<AssistantSessionRecord>, AssistantSessionError> {
354        Ok(self.inner.store.get_assistant_session(&session_id).await?)
355    }
356
357    /// A session's projected state and cause.
358    ///
359    /// The public read of [`Self::state_of`], for the assistant MCP route: a
360    /// bearer is honoured only while the session it names is not `ended`, and
361    /// that decision has to read the same projection every other surface reads.
362    ///
363    /// # Errors
364    ///
365    /// Whatever the store reports.
366    pub async fn state_of_session(
367        &self,
368        session_id: AssistantSessionId,
369    ) -> Result<(AssistantSessionState, Option<String>), AssistantSessionError> {
370        self.state_of(session_id).await
371    }
372
373    /// Every session this caller owns, newest first.
374    ///
375    /// CALLER-scoped, not namespace-scoped: a session is one operator's
376    /// conversation with an agent and lives in no namespace. The store
377    /// enumerates what it holds and the narrowing happens here, where the caller
378    /// identity is.
379    ///
380    /// # Errors
381    ///
382    /// Whatever the store reports.
383    pub async fn list(
384        &self,
385        subject: &str,
386    ) -> Result<Vec<AssistantSessionSummary>, AssistantSessionError> {
387        let listing = self.inner.store.list_assistant_sessions().await?;
388        for row in &listing.undecodable {
389            // Named, never silent: an operator whose session is unreadable must
390            // be able to find out from the log that it EXISTS, even though it
391            // cannot appear in a typed listing.
392            tracing::warn!(
393                session = %row.session_id,
394                error = %row.error,
395                "an assistant session record could not be decoded and is omitted from the listing"
396            );
397        }
398        let mut summaries = Vec::new();
399        for record in listing.sessions {
400            if record.subject != subject {
401                continue;
402            }
403            let session_id = record.session_id;
404            let (state, reason) = self.state_of(session_id).await?;
405            summaries.push(record.summary(state, reason));
406        }
407        // Newest first: the operator's current conversation is the one they
408        // came for.
409        summaries.sort_by_key(|summary| std::cmp::Reverse(summary.created_at));
410        Ok(summaries)
411    }
412
413    /// A session's state and cause.
414    ///
415    /// A running process always wins. With no process, the LAST record that
416    /// settled the session decides — read from the transcript's tail rather than
417    /// its whole length, because a listing must not cost one full conversation
418    /// read per row. The boot sweep guarantees a settling record is the last
419    /// thing on a non-live session's transcript; when the tail does not carry
420    /// one, the whole transcript is read rather than a state guessed.
421    pub(crate) async fn state_of(
422        &self,
423        session_id: AssistantSessionId,
424    ) -> Result<(AssistantSessionState, Option<String>), AssistantSessionError> {
425        if self.is_live(session_id).await {
426            return Ok((AssistantSessionState::Live, None));
427        }
428        let head = self
429            .inner
430            .store
431            .assistant_transcript_head(&session_id)
432            .await?;
433        let tail_from = head.saturating_sub(SETTLEMENT_TAIL);
434        let tail = self
435            .transcript_from(session_id, tail_from.checked_sub(1))
436            .await?;
437        let projection = AssistantSessionProjection::of(tail.iter().map(|frame| &frame.event));
438        if projection.settled.is_some() {
439            return Ok(projection.state(None));
440        }
441        // The tail carried no settlement. Rather than guess, read the whole
442        // conversation: this is bounded by one session and only happens on a
443        // transcript the boot sweep has not written back to.
444        let whole = self.transcript_from(session_id, None).await?;
445        Ok(AssistantSessionProjection::of(whole.iter().map(|frame| &frame.event)).state(None))
446    }
447
448    /// Whether a process is running for this session.
449    pub(crate) async fn is_live(&self, session_id: AssistantSessionId) -> bool {
450        match self.live(session_id) {
451            Some(live) => live.is_alive().await,
452            None => false,
453        }
454    }
455
456    /// A session's transcript as wire frames, strictly after `after`.
457    ///
458    /// # Errors
459    ///
460    /// Whatever the store reports, or
461    /// [`AssistantSessionError::Internal`] when a stored frame cannot be
462    /// decoded — reported rather than skipped, because a transcript with a hole
463    /// in it that nobody mentions is worse than one that refuses to be read.
464    pub(crate) async fn transcript_from(
465        &self,
466        session_id: AssistantSessionId,
467        after: Option<u64>,
468    ) -> Result<Vec<AssistantSessionFrame>, AssistantSessionError> {
469        let stored = self
470            .inner
471            .store
472            .assistant_transcript(&session_id, after)
473            .await?;
474        stored.iter().map(decode_frame).collect()
475    }
476
477    /// The projection over a session's whole transcript — every fact the resume
478    /// path and the context tool read.
479    ///
480    /// # Errors
481    ///
482    /// Whatever [`Self::transcript_from`] reports.
483    pub(crate) async fn projection(
484        &self,
485        session_id: AssistantSessionId,
486    ) -> Result<AssistantSessionProjection, AssistantSessionError> {
487        let frames = self.transcript_from(session_id, None).await?;
488        Ok(AssistantSessionProjection::of(
489            frames.iter().map(|frame| &frame.event),
490        ))
491    }
492
493    /// The most recently shared on-screen context for a session.
494    ///
495    /// What the `assistant_context` MCP tool answers with, read off the
496    /// transcript: the transcript IS the shared context, so there is no second
497    /// store to keep in step and a restart loses nothing.
498    ///
499    /// # Errors
500    ///
501    /// Whatever [`Self::projection`] reports.
502    pub async fn latest_context(
503        &self,
504        session_id: AssistantSessionId,
505    ) -> Result<Option<AssistantTurnContext>, AssistantSessionError> {
506        Ok(self.projection(session_id).await?.latest_context)
507    }
508
509    /// Record a state transition and its cause.
510    pub(crate) async fn settle(
511        &self,
512        session_id: AssistantSessionId,
513        state: AssistantSessionState,
514        reason: impl Into<String>,
515    ) -> Result<(), AssistantSessionError> {
516        let reason = reason.into();
517        // Ended is terminal: no settling record may move a session out of it.
518        // The projection already ignores such a frame; refusing to write one
519        // keeps the transcript from carrying a state it will never project.
520        if state != AssistantSessionState::Ended
521            && let Some((AssistantSessionState::Ended, cause)) =
522                self.projection(session_id).await?.settled
523        {
524            tracing::warn!(
525                %session_id,
526                requested = ?state,
527                %reason,
528                "a settling record that would leave `ended` was refused: ended is terminal"
529            );
530            return Err(AssistantSessionError::Ended {
531                session_id,
532                reason: cause.unwrap_or_else(|| "ended".to_owned()),
533            });
534        }
535        self.recorder(session_id)
536            .record(AssistantSessionEvent::State {
537                state,
538                reason: Some(reason),
539            })
540            .await
541            .map(drop)
542    }
543
544    /// Update a session's bookkeeping — the fields a listing reads without
545    /// touching a transcript.
546    ///
547    /// The record is RE-READ from the store here rather than taken from the
548    /// caller. A turn holds its copy of the record across the spawn, and the
549    /// spawn stores the token digest of the secret it actually handed the
550    /// child; writing the caller's copy back would silently restore the digest
551    /// that copy remembers — a bearer no live child holds, so every MCP call
552    /// the agent makes would fail verification from that write on. Every
553    /// writer of this record mutates what the store holds NOW, never a copy
554    /// carried across an await.
555    pub(crate) async fn touch(
556        &self,
557        session_id: AssistantSessionId,
558        first_turn_text: Option<&str>,
559    ) -> Result<(), AssistantSessionError> {
560        let Some(mut record) = self.inner.store.get_assistant_session(&session_id).await? else {
561            return Err(AssistantSessionError::NotFound { session_id });
562        };
563        record.updated_at = Utc::now();
564        record.turns = record.turns.saturating_add(1);
565        if record.title.is_none()
566            && let Some(text) = first_turn_text
567        {
568            record.title = Some(text.trim().chars().take(TITLE_CHARACTERS).collect());
569        }
570        self.inner.store.put_assistant_session(record).await?;
571        Ok(())
572    }
573}
574
575/// Whether this server can open assistant sessions.
576#[derive(Clone, Debug, PartialEq, Eq)]
577pub enum Availability {
578    /// Sessions can be opened.
579    Available,
580    /// They cannot, and this is why — in words an operator can act on.
581    Unavailable {
582        /// The reason, for the descriptor and the panel.
583        reason: String,
584    },
585}
586
587impl Availability {
588    /// Whether sessions can be opened.
589    #[must_use]
590    pub const fn is_available(&self) -> bool {
591        matches!(self, Self::Available)
592    }
593
594    /// The reason sessions are off, or `None` when they are on.
595    #[must_use]
596    pub fn reason(&self) -> Option<&str> {
597        match self {
598            Self::Available => None,
599            Self::Unavailable { reason } => Some(reason),
600        }
601    }
602}
603
604/// How many trailing transcript events a state projection reads before falling
605/// back to the whole conversation.
606///
607/// Not a tuning knob and not a cap on anything: the boot sweep guarantees the
608/// LAST event of a session with no process is the record that settled it, so one
609/// event would do. A few are read because a settlement immediately followed by,
610/// say, a straggling frame is cheap to tolerate and expensive to be wrong about,
611/// and the fallback reads everything rather than guessing.
612const SETTLEMENT_TAIL: u64 = 8;
613
614/// How many live frames a subscriber may fall behind by before it is dropped.
615///
616/// A BACKPRESSURE bound, not an operator knob: the round-2 amendment retired the
617/// `event_buffer` setting because the number is not a policy anybody can choose
618/// usefully — a socket that cannot keep up with a token stream has to be told so
619/// rather than allowed to consume the server's memory. The durable transcript is
620/// what a dropped subscriber reads to catch up (`?after=`), so falling behind
621/// costs a reconnect and never a lost frame.
622const LIVE_FRAME_BUFFER: usize = 1_024;
623
624/// The first half of the sentence a server with an unusable store answers with.
625///
626/// The one reason a stock server states for sessions being off, and it names a
627/// real fault rather than a missing configuration — there is no configuration to
628/// miss.
629pub const STORE_UNUSABLE: &str = "the durable store this server keeps assistant sessions in could not be read at start-up, so \
630     a conversation could not be recorded";
631
632/// Decode one stored transcript row into the wire frame it holds.
633fn decode_frame(
634    stored: &AssistantTranscriptEvent,
635) -> Result<AssistantSessionFrame, AssistantSessionError> {
636    let event: AssistantSessionEvent =
637        serde_json::from_slice(stored.payload.bytes()).map_err(|error| {
638            AssistantSessionError::Internal(format!(
639                "the assistant transcript event at index {} does not decode as a session frame \
640                 ({error}); the transcript is reported as unreadable rather than served with a \
641                 hole in it",
642                stored.index
643            ))
644        })?;
645    Ok(AssistantSessionFrame {
646        index: stored.index,
647        event,
648    })
649}