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}