aion_server/assistant/sessions/lifecycle.rs
1//! Creating, turning, resuming, cancelling and ending a session.
2//!
3//! # A session is created, not spawned
4//!
5//! ACP has no way to open a conversation without asking something: `session/new`
6//! is followed by `session/prompt` in one opening, and there is no promptless
7//! handshake to run at create time. So creating a session RECORDS it — and the
8//! FIRST TURN is what starts a process, carrying the operator's own words as the
9//! opening prompt.
10//!
11//! That is also why "not logged in" is a turn's `auth_required` code and never a
12//! session state: the `-32000` an unauthenticated agent answers with arrives on
13//! a prompt, so it belongs to the turn that asked.
14//!
15//! # Resume is gated on what the agent ACTUALLY said
16//!
17//! A session whose process is gone is `dormant` only when its last opening
18//! recorded `load_session: true` — what that agent advertised at `initialize`,
19//! not what agents of its kind usually support. When it did not, the session is
20//! settled `ended` by an appended record naming the capability, and the next
21//! turn is refused rather than spawning a process that would be asked for a
22//! `session/load` it cannot answer.
23
24use std::sync::Arc;
25
26use aion_core::{
27 AssistantCommandInvocation, AssistantConfigOption, AssistantConfigValue, AssistantSessionEvent,
28 AssistantSessionFrame, AssistantSessionId, AssistantSessionProjection, AssistantSessionState,
29 AssistantSessionSummary, AssistantTurnContext, ContentType, Payload,
30};
31use aion_integration_acp::catalogue::{self, CatalogueHarness};
32use aion_integration_acp::{AcpConfigValue, TurnHandle};
33use aion_integrations::{ActivityId, AgentHarness, AgentRunSpec, RunId, WorkflowId};
34use aion_store::assistant::AssistantSessionRecord;
35use chrono::Utc;
36use tokio::sync::broadcast;
37
38use crate::config::ResolvedAssistantAccount;
39
40use super::error::AssistantSessionError;
41use super::frames;
42use super::launch;
43use super::live::LiveSession;
44use super::prompt;
45use super::registry::{AssistantSessions, Availability};
46use super::turn_driver;
47
48use crate::assistant::grounding;
49
50impl AssistantSessions {
51 /// Open a session on a catalogue harness.
52 ///
53 /// Records the session; the harness starts on the first turn. The returned
54 /// summary therefore reports `dormant` — no process, and continuable — which
55 /// is the truthful answer for a conversation nobody has said anything in
56 /// yet.
57 ///
58 /// The pick is REMEMBERED, caller-scoped, so the next new-conversation form
59 /// opens on the harness this operator last used. It is written from what
60 /// they actually opened rather than from a preference they set, and it is
61 /// written only after the session record exists — a remembered pick for a
62 /// session that was refused would be a choice nobody made.
63 ///
64 /// # Errors
65 ///
66 /// [`AssistantSessionError::NotCommissioned`] when the store is unusable,
67 /// [`AssistantSessionError::UnknownHarness`] / `UnknownAccount` when the
68 /// caller names one that does not exist,
69 /// [`AssistantSessionError::HarnessUnavailable`] when the chosen harness's
70 /// launch program is not on this server's `PATH` — refused HERE rather than
71 /// accepted and failed at the first message — or whatever the store reports.
72 pub async fn create(
73 &self,
74 subject: &str,
75 harness: Option<&str>,
76 account: Option<&str>,
77 title: Option<String>,
78 ) -> Result<AssistantSessionSummary, AssistantSessionError> {
79 if let Availability::Unavailable { reason } = self.availability() {
80 tracing::warn!(%reason, "an assistant session was requested on a server with none");
81 return Err(AssistantSessionError::NotCommissioned { reason });
82 }
83 let harness = self.resolve_harness(subject, harness).await?;
84 let account = self.resolve_account(harness.id, account)?;
85 let session_id = AssistantSessionId::new_v4();
86 // The plan is built here to MEASURE the harness: an unavailable one is
87 // refused with the same typed shape the spawn would produce, so a
88 // session is never accepted on a harness this machine cannot run. Its
89 // token is DISCARDED — `mint()` is random per call, so a digest stored
90 // now would name a secret no child will ever hold. The ONE mint that
91 // counts happens at spawn, which stores the digest of the secret it
92 // actually hands the child; until then the record carries no digest and
93 // the MCP route refuses every bearer with `NoTokenMinted`, which is the
94 // truth of a session that has no process.
95 let plan = launch::plan(session_id, harness, account, self.aion_endpoint())?;
96 drop(plan);
97 let now = Utc::now();
98 let record = AssistantSessionRecord {
99 session_id,
100 subject: subject.to_owned(),
101 harness: harness.id.to_owned(),
102 account: account.map(|account| account.name.clone()),
103 title,
104 created_at: now,
105 updated_at: now,
106 turns: 0,
107 mcp_token_digest: None,
108 // Nothing is offered until the harness itself advertises something:
109 // a command list this server composed would be a list the agent
110 // never promised to serve.
111 commands: Vec::new(),
112 config_options: Vec::new(),
113 };
114 self.store().put_assistant_session(record.clone()).await?;
115 // Written after the record and before the summary: the memory is of a
116 // session that exists.
117 self.store()
118 .put_assistant_default_harness(subject, harness.id)
119 .await?;
120 // The session's FIRST record says what it is: no process, and waiting
121 // for something to be said. Without it the projection would meet a
122 // transcript with no settling record and report the fallback.
123 self.settle(
124 session_id,
125 AssistantSessionState::Dormant,
126 CREATED_AWAITING_FIRST_TURN,
127 )
128 .await?;
129 Ok(record.summary(
130 AssistantSessionState::Dormant,
131 Some(CREATED_AWAITING_FIRST_TURN.to_owned()),
132 ))
133 }
134
135 /// The caller's CURRENT session: the newest one that is not ended.
136 ///
137 /// One thread is the shape an operator actually holds — the dock panel and
138 /// the inline editor bar are two views of ONE conversation, not two
139 /// conversations — so "which session am I in" is a first-class read rather
140 /// than something each surface derives from a list and could derive
141 /// differently.
142 ///
143 /// `Ok(None)` when the caller has no continuable session. That is an
144 /// absence, not a refusal: a caller with no session is the ordinary state
145 /// of somebody who has not started one.
146 ///
147 /// # Errors
148 ///
149 /// Whatever the store reports.
150 pub async fn current(
151 &self,
152 subject: &str,
153 ) -> Result<Option<AssistantSessionSummary>, AssistantSessionError> {
154 // `list` is already newest-first and already projects each state, so the
155 // current session is the first row that may be current — continuable
156 // and not put away by the caller. Deriving it here rather
157 // than in each surface means two surfaces cannot disagree about which
158 // conversation the operator is in.
159 Ok(self
160 .list(subject)
161 .await?
162 .into_iter()
163 .find(|summary| summary.state.is_current_candidate()))
164 }
165
166 /// A session's summary and its whole transcript.
167 ///
168 /// # Errors
169 ///
170 /// Not found (including another subject's session), or whatever the store
171 /// reports.
172 pub async fn read(
173 &self,
174 subject: &str,
175 session_id: AssistantSessionId,
176 ) -> Result<(AssistantSessionSummary, Vec<AssistantSessionFrame>), AssistantSessionError> {
177 let record = self.owned_record(subject, session_id).await?;
178 let frames = self.transcript_from(session_id, None).await?;
179 let live = self.is_live(session_id).await;
180 let projection = AssistantSessionProjection::of(frames.iter().map(|frame| &frame.event));
181 let (state, reason) = projection.state(live.then_some(AssistantSessionState::Live));
182 Ok((record.summary(state, reason), frames))
183 }
184
185 /// The transcript so far, then every frame as it arrives.
186 ///
187 /// The replay is read BEFORE the subscription is taken so nothing can land
188 /// between the two: a frame committed after the read and before the
189 /// subscribe would be in neither, and a client would have a hole it could
190 /// not see. Taking the receiver first means the worst case is a DUPLICATE
191 /// frame, which a client can drop by index.
192 ///
193 /// # Errors
194 ///
195 /// Not found, or whatever the store reports.
196 pub async fn watch(
197 &self,
198 subject: &str,
199 session_id: AssistantSessionId,
200 after: Option<u64>,
201 ) -> Result<
202 (
203 Vec<AssistantSessionFrame>,
204 broadcast::Receiver<AssistantSessionFrame>,
205 ),
206 AssistantSessionError,
207 > {
208 let _record = self.owned_record(subject, session_id).await?;
209 let receiver = self.recorder(session_id).subscribe();
210 let replay = self.transcript_from(session_id, after).await?;
211 Ok((replay, receiver))
212 }
213
214 /// Share the operator's on-screen context without asking anything.
215 ///
216 /// Appended to the transcript, which IS the shared context: the harness's
217 /// `assistant_context` tool reads the latest one, and both console surfaces
218 /// read the same record. There is no second store to keep in step.
219 ///
220 /// # Errors
221 ///
222 /// Not found, or whatever the store reports.
223 pub async fn push_context(
224 &self,
225 subject: &str,
226 session_id: AssistantSessionId,
227 context: AssistantTurnContext,
228 ) -> Result<(), AssistantSessionError> {
229 let _record = self.owned_record(subject, session_id).await?;
230 self.recorder(session_id)
231 .record(AssistantSessionEvent::ContextShared {
232 context,
233 source: CONTEXT_SOURCE_PUSH.to_owned(),
234 })
235 .await
236 .map(drop)
237 }
238
239 /// Ask the agent something.
240 ///
241 /// Starts the harness when none is running — freshly for a session that has
242 /// never opened, or with `session/load` for a dormant one whose agent can
243 /// reload its own conversation.
244 ///
245 /// # Errors
246 ///
247 /// Not found; [`AssistantSessionError::Busy`] when a turn is already open;
248 /// [`AssistantSessionError::Ended`] when the session cannot be continued;
249 /// [`AssistantSessionError::HarnessFailed`] when the harness will not start.
250 pub async fn turn(
251 &self,
252 subject: &str,
253 session_id: AssistantSessionId,
254 text: String,
255 context: Option<AssistantTurnContext>,
256 command: Option<AssistantCommandInvocation>,
257 ) -> Result<String, AssistantSessionError> {
258 let record = self.owned_record(subject, session_id).await?;
259 if let Some(command) = command.as_ref() {
260 self.require_advertised(&record, command).await?;
261 }
262 // A command is DELIVERED as prompt text — `/name` and whatever follows
263 // it — because that is the delivery form the ACP schema describes: its
264 // only input shape is "all text that was typed after the command name".
265 // There is no second transport to take, so the composition is the whole
266 // of it, and the structured invocation is recorded beside the line so
267 // the transcript still says a command was pressed.
268 let asked = command
269 .as_ref()
270 .map_or_else(|| text.clone(), AssistantCommandInvocation::prompt_line);
271 let composed = prompt::compose(context.as_ref(), &asked);
272 // The FIRST turn of a session opens with the grounding preamble: where
273 // the agent is standing and where this server's reference pack lives.
274 // Server-side grounding, not operator context — it goes around the
275 // composed prompt rather than through the turn-context formatter,
276 // whose byte-for-byte mirror contract with the console is about what
277 // the operator's screen said and nothing else. Once per session: on
278 // every turn it would be noise the agent learns to read past, and a
279 // resumed conversation already carries it in the history the agent
280 // loads. The spawn (which a first turn always performs) rewrites the
281 // pack itself before the child starts.
282 let composed = if record.turns == 0 {
283 let dir = grounding::directory().map_err(AssistantSessionError::Internal)?;
284 format!("{}\n\n{composed}", grounding::preamble(&dir))
285 } else {
286 composed
287 };
288 let turn_id = uuid::Uuid::new_v4().to_string();
289
290 let (live, opening) = match self.ensure_live(&record, &composed).await {
291 Ok(started) => started,
292 Err(error) => {
293 // The spawn is where a missing binary, an absent account
294 // variable or a refused resume is actually met, and the operator
295 // is watching the SOCKET. So the refusal is recorded as this
296 // turn's own failure — carrying the typed code and the message
297 // with the install hint in it — before it is returned. A turn
298 // that never reached an agent still has to say so on the
299 // transcript, or a reloaded conversation shows a question with
300 // no answer and no reason.
301 self.record_turn_failure(session_id, &turn_id, &error).await;
302 return Err(error);
303 }
304 };
305 // Claimed AFTER the spawn: a fresh spawn's opening prompt IS this turn,
306 // so the slot has to be claimed against the session that now exists.
307 live.claim_turn()?;
308 let started = self
309 .record_turn_start(&live, &turn_id, context, &asked, command, &composed)
310 .await;
311 if let Err(error) = started {
312 live.release_turn();
313 return Err(error);
314 }
315
316 // The spawn's own prompt IS this turn, so a fresh spawn already handed
317 // back the handle; only a session that was already live has to submit
318 // one.
319 // A fresh spawn's opening prompt IS this turn, so it already handed back
320 // the handle; only a session that was already live has to submit one.
321 let handle = if let Some(handle) = opening {
322 handle
323 } else {
324 let submitted = live
325 .with_session(async |session| session.prompt(composed.clone()).await)
326 .await;
327 match submitted {
328 Some(Ok(handle)) => handle,
329 Some(Err(error)) => {
330 live.release_turn();
331 return Err(AssistantSessionError::HarnessFailed {
332 harness: record.harness.clone(),
333 reason: error.to_string(),
334 });
335 }
336 None => {
337 live.release_turn();
338 return Err(AssistantSessionError::Ended {
339 session_id,
340 reason: "the harness process has been shut down".to_owned(),
341 });
342 }
343 }
344 };
345
346 self.touch(session_id, Some(&asked)).await?;
347 // No per-turn timeout: a turn ends when the harness ends it or the
348 // caller cancels it (RULED 2026-08-29). A number here would cancel
349 // somebody's real work mid-thought, and there is no honest value for
350 // "how long may an agent think".
351 let driver_turn = turn_id.clone();
352 tokio::spawn(turn_driver::drive(live, handle, driver_turn));
353 Ok(turn_id)
354 }
355
356 /// Record a refusal that stopped a turn before it reached the agent.
357 ///
358 /// Best-effort by construction: the caller is already returning the typed
359 /// error, and a store that cannot take this frame is a store that could not
360 /// have taken the turn either. It is logged rather than swallowed, and it
361 /// never replaces the refusal the caller gets.
362 async fn record_turn_failure(
363 &self,
364 session_id: AssistantSessionId,
365 turn_id: &str,
366 error: &AssistantSessionError,
367 ) {
368 if let Err(recording) = self
369 .recorder(session_id)
370 .record(AssistantSessionEvent::TurnFailed {
371 turn_id: turn_id.to_owned(),
372 code: error.code().to_owned(),
373 message: error.to_string(),
374 })
375 .await
376 {
377 tracing::warn!(
378 session = %session_id,
379 %recording,
380 original = %error,
381 "an assistant turn failed before it reached the agent, and the failure could not \
382 be recorded on its transcript"
383 );
384 }
385 }
386
387 /// Stop the open turn.
388 ///
389 /// # Errors
390 ///
391 /// Not found, or [`AssistantSessionError::Ended`] when no process is
392 /// running to cancel.
393 pub async fn cancel(
394 &self,
395 subject: &str,
396 session_id: AssistantSessionId,
397 ) -> Result<(), AssistantSessionError> {
398 let _record = self.owned_record(subject, session_id).await?;
399 let Some(live) = self.live(session_id) else {
400 return Err(AssistantSessionError::Ended {
401 session_id,
402 reason: "no harness process is running for this session".to_owned(),
403 });
404 };
405 turn_driver::cancel(&live).await
406 }
407
408 /// Set one advertised configuration option on a live session — the model
409 /// picker's write side.
410 ///
411 /// Refused BY NAME when the harness has not advertised the option, or (for
412 /// a select) the value: a client offers what the session's
413 /// `config_options` say, and anything else is a control that should never
414 /// have been on the screen — the exact rule turns follow for commands. The
415 /// agent's answer, the full option set as it now stands, is recorded on
416 /// the transcript exactly as an advertisement is (which also refreshes the
417 /// record's cache), and returned.
418 ///
419 /// # Errors
420 ///
421 /// Not found / not yours; [`AssistantSessionError::UnknownConfigOption`]
422 /// for an unadvertised option or value, or a value of the wrong shape;
423 /// [`AssistantSessionError::Ended`] when no process is running;
424 /// [`AssistantSessionError::HarnessFailed`] when the agent refuses, the
425 /// transport fails, or the answer cannot be read as an option set.
426 pub async fn set_config_option(
427 &self,
428 subject: &str,
429 session_id: AssistantSessionId,
430 option_id: &str,
431 value: &serde_json::Value,
432 ) -> Result<Vec<AssistantConfigOption>, AssistantSessionError> {
433 let record = self.owned_record(subject, session_id).await?;
434 let chosen = chosen_config_value(&record.config_options, session_id, option_id, value)?;
435 let Some(live) = self.live(session_id) else {
436 return Err(AssistantSessionError::Ended {
437 session_id,
438 reason: "no harness process is running for this session".to_owned(),
439 });
440 };
441 let delivered = live
442 .with_session(async |session| session.set_config_option(option_id, chosen).await)
443 .await;
444 let answer = match delivered {
445 None => {
446 return Err(AssistantSessionError::Ended {
447 session_id,
448 reason: "the harness process has already been shut down".to_owned(),
449 });
450 }
451 Some(Err(error)) => {
452 return Err(AssistantSessionError::HarnessFailed {
453 harness: record.harness.clone(),
454 reason: error.to_string(),
455 });
456 }
457 Some(Ok(answer)) => answer,
458 };
459 let Some(options) = frames::config_options(&answer) else {
460 // The change may have applied — the agent answered — but the
461 // answer cannot be read as an option set. Recorded verbatim so
462 // the record is complete, and reported loudly rather than
463 // returned as an empty set (an empty set is a withdrawal).
464 live.recorder()
465 .record(AssistantSessionEvent::Raw {
466 turn_id: None,
467 source: "session/set_config_option/response".to_owned(),
468 value: answer,
469 })
470 .await?;
471 return Err(AssistantSessionError::HarnessFailed {
472 harness: record.harness.clone(),
473 reason: "the agent answered `session/set_config_option` with an unreadable \
474 option set; the transcript holds the answer verbatim"
475 .to_owned(),
476 });
477 };
478 live.recorder()
479 .record(AssistantSessionEvent::ConfigOptions {
480 options: options.clone(),
481 })
482 .await?;
483 Ok(options)
484 }
485
486 /// Say whether a session can be continued, settling it when it cannot.
487 ///
488 /// There is no promptless spawn to perform here — ACP opens a conversation
489 /// by asking something — so this does not start a process. What it does is
490 /// make the resume decision READABLE before an operator types: a session
491 /// whose agent never advertised `loadSession` is settled `ended` by an
492 /// appended record naming the capability, so the panel can say so instead of
493 /// offering a box that would refuse.
494 ///
495 /// # Errors
496 ///
497 /// Not found, or whatever the store reports.
498 pub async fn resume(
499 &self,
500 subject: &str,
501 session_id: AssistantSessionId,
502 ) -> Result<AssistantSessionSummary, AssistantSessionError> {
503 let record = self.owned_record(subject, session_id).await?;
504 if self.is_live(session_id).await {
505 return Ok(record.summary(AssistantSessionState::Live, None));
506 }
507 let projection = self.projection(session_id).await?;
508 if projection.acp_session_ref.is_none() || projection.is_resumable() {
509 let (state, reason) = projection.state(None);
510 return Ok(record.summary(state, reason));
511 }
512 self.settle(session_id, AssistantSessionState::Ended, RESUME_REFUSED)
513 .await?;
514 Ok(record.summary(
515 AssistantSessionState::Ended,
516 Some(RESUME_REFUSED.to_owned()),
517 ))
518 }
519
520 /// Put a session away: shut its harness down and settle it.
521 ///
522 /// The transcript is KEPT. Deleting a session deletes a process, not a
523 /// record — an operator reading back what an agent did a week ago is
524 /// exactly who this surface exists for.
525 ///
526 /// What it settles to follows the same fact every resume decision follows:
527 /// whether the agent can reload the conversation. A session whose agent
528 /// advertised `loadSession` and left a handle settles
529 /// [`AssistantSessionState::Closed`] — out of the operator's way, never
530 /// current, but reopened by the next turn taken on it from history. One
531 /// whose agent cannot reload settles [`AssistantSessionState::Ended`], and
532 /// the transcript is all that is left of it.
533 ///
534 /// # Errors
535 ///
536 /// Not found, or whatever the store reports.
537 pub async fn delete(
538 &self,
539 subject: &str,
540 session_id: AssistantSessionId,
541 ) -> Result<(), AssistantSessionError> {
542 let _record = self.owned_record(subject, session_id).await?;
543 if let Some(live) = self.forget(session_id) {
544 live.close().await;
545 }
546 // The per-session edit lock dies with the process. A call still holding
547 // its `Arc` finishes safely; nothing new is minted until a turn spawns
548 // a fresh child, and a fresh child is handed a fresh bearer.
549 self.document_edit_locks().remove(&session_id);
550 self.fail_open_turn(session_id, "process_exited").await?;
551 let projection = self.projection(session_id).await?;
552 if projection.is_resumable() {
553 return self
554 .settle(session_id, AssistantSessionState::Closed, CLOSED_RESUMABLE)
555 .await;
556 }
557 self.settle(session_id, AssistantSessionState::Ended, DELETED)
558 .await?;
559 self.recorder(session_id)
560 .record(AssistantSessionEvent::Ended {
561 reason: DELETED.to_owned(),
562 })
563 .await
564 .map(drop)
565 }
566
567 /// Settle every session whose process is gone, at boot.
568 ///
569 /// WRITTEN BACK, not merely displayed: the settlement is an appended record
570 /// with its cause, so the next reader projects it rather than recomputing
571 /// the same decision — and so the decision itself is auditable.
572 ///
573 /// Returns how many sessions were settled.
574 ///
575 /// # Errors
576 ///
577 /// Whatever the store reports.
578 pub async fn sweep_orphans(&self) -> Result<usize, AssistantSessionError> {
579 match self.sweep_orphans_inner().await {
580 Ok(settled) => {
581 // The sweep is this server's first end-to-end use of the
582 // assistant store, so it is where "can this server hold a
583 // conversation at all" is actually answered — and answering it
584 // is what puts a real sentence in `sessions_disabled_reason`
585 // instead of leaving the field for a configuration question that
586 // no longer exists.
587 self.clear_store_fault();
588 Ok(settled)
589 }
590 Err(error) => {
591 self.report_store_fault(&error);
592 Err(error)
593 }
594 }
595 }
596
597 /// Fail the turn a dying process leaves open, BEFORE the session settles.
598 ///
599 /// A `Request` is appended at acceptance; its answer arrives only from the
600 /// process. When the process is gone, no `TurnCompleted`/`TurnFailed` will
601 /// ever come, and a transcript that settles around an open turn folds as
602 /// busy forever — the console refuses new turns, and a later edit batch
603 /// would be attributed to a question that ended with the process. So every
604 /// settle path for a dead process closes the record's open question first,
605 /// with a `TurnFailed` that says exactly what happened.
606 async fn fail_open_turn(
607 &self,
608 session_id: AssistantSessionId,
609 code: &str,
610 ) -> Result<(), AssistantSessionError> {
611 let projection = self.projection(session_id).await?;
612 let Some(turn_id) = projection.open_turn_id else {
613 return Ok(());
614 };
615 self.recorder(session_id)
616 .record(AssistantSessionEvent::TurnFailed {
617 turn_id,
618 code: code.to_owned(),
619 message: OPEN_TURN_ORPHANED.to_owned(),
620 })
621 .await
622 .map(drop)
623 }
624
625 /// The sweep itself; [`Self::sweep_orphans`] records what it found out about
626 /// the store.
627 async fn sweep_orphans_inner(&self) -> Result<usize, AssistantSessionError> {
628 let listing = self.store().list_assistant_sessions().await?;
629 let mut settled = 0_usize;
630 for record in listing.sessions {
631 let session_id = record.session_id;
632 if self.is_live(session_id).await {
633 continue;
634 }
635 let projection = self.projection(session_id).await?;
636 if projection.settled.is_some() {
637 continue;
638 }
639 let (state, reason) = if projection.is_resumable() {
640 (
641 AssistantSessionState::Dormant,
642 PROCESS_EXITED_RESUMABLE.to_owned(),
643 )
644 } else {
645 (
646 AssistantSessionState::Ended,
647 PROCESS_EXITED_ENDED.to_owned(),
648 )
649 };
650 self.fail_open_turn(session_id, "process_exited").await?;
651 self.settle(session_id, state, reason).await?;
652 settled = settled.saturating_add(1);
653 }
654 if settled > 0 {
655 tracing::info!(
656 settled,
657 "assistant sessions whose harness process is gone were settled at boot"
658 );
659 }
660 Ok(settled)
661 }
662
663 /// Shut every live harness down — the server is stopping.
664 pub async fn shutdown(&self) {
665 let ids: Vec<AssistantSessionId> = self.live_ids();
666 for session_id in ids {
667 if let Some(live) = self.forget(session_id) {
668 live.close().await;
669 }
670 if let Err(error) = self.fail_open_turn(session_id, "server_stopped").await {
671 tracing::warn!(
672 session = %session_id,
673 %error,
674 "an assistant session's open turn could not be failed while the server \
675 stopped; the transcript keeps an unanswered question until the boot sweep"
676 );
677 }
678 if let Err(error) = self
679 .settle(session_id, AssistantSessionState::Dormant, SERVER_STOPPED)
680 .await
681 {
682 tracing::warn!(
683 session = %session_id,
684 %error,
685 "an assistant session could not be settled while the server stopped; the boot \
686 sweep will settle it on the next start"
687 );
688 }
689 }
690 }
691
692 /// Start a harness for this session if none is running, returning it and —
693 /// for a fresh spawn — the opening turn's handle.
694 async fn ensure_live(
695 &self,
696 record: &AssistantSessionRecord,
697 composed_prompt: &str,
698 ) -> Result<(Arc<LiveSession>, Option<TurnHandle>), AssistantSessionError> {
699 let session_id = record.session_id;
700 // Serialised per session: two turns arriving at once on a session with
701 // no process must not both spawn one.
702 let spawn_lock = self.spawn_lock(session_id);
703 let _held = spawn_lock.lock().await;
704 if let Some(live) = self.live(session_id)
705 && live.is_alive().await
706 {
707 return Ok((live, None));
708 }
709 let projection = self.projection(session_id).await?;
710 // Ended is terminal, whichever door a turn arrives through. The resume
711 // path below refuses by name when the agent cannot reload; this refusal
712 // covers the OTHER door — a session that never opened (or whose ended
713 // record predates any open) would otherwise fall through to a fresh
714 // spawn and come back `live` under the id the caller deleted.
715 if matches!(projection.settled, Some((AssistantSessionState::Ended, _))) {
716 tracing::warn!(
717 %session_id,
718 harness = %record.harness,
719 "a turn on an ended assistant session was refused: ended is terminal"
720 );
721 return Err(AssistantSessionError::Ended {
722 session_id,
723 reason: ENDED_IS_TERMINAL.to_owned(),
724 });
725 }
726 let prior = if projection.acp_session_ref.is_none() {
727 None
728 } else if projection.is_resumable() {
729 projection.acp_session_ref.clone()
730 } else {
731 self.settle(session_id, AssistantSessionState::Ended, RESUME_REFUSED)
732 .await?;
733 return Err(AssistantSessionError::Ended {
734 session_id,
735 reason: RESUME_REFUSED.to_owned(),
736 });
737 };
738 let (live, handle) = self.spawn(record, composed_prompt, prior).await?;
739 Ok((live, Some(handle)))
740 }
741
742 /// Spawn the harness and adopt it.
743 async fn spawn(
744 &self,
745 record: &AssistantSessionRecord,
746 composed_prompt: &str,
747 prior: Option<String>,
748 ) -> Result<(Arc<LiveSession>, TurnHandle), AssistantSessionError> {
749 let session_id = record.session_id;
750 // The RECORD's harness, never the caller's last pick: a conversation
751 // runs on the harness it was opened on, whatever the operator has picked
752 // since.
753 let harness = self.harness_entry(&record.harness).ok_or_else(|| {
754 AssistantSessionError::UnknownHarness {
755 requested: record.harness.clone(),
756 declared: self.declared_harnesses(),
757 }
758 })?;
759 let account = match record.account.as_deref() {
760 Some(name) => Some(self.resolve_account_by_name(harness.id, name)?),
761 None => None,
762 };
763 // Availability is RE-MEASURED inside the plan: `npx` may have been
764 // uninstalled since the session was created, and a stale "available"
765 // would surface as an unexplained spawn failure instead of the
766 // catalogue's install hint.
767 let plan = launch::plan(session_id, harness, account, self.aion_endpoint())?;
768 // THE one mint. The child is handed `plan.token`'s secret, so the
769 // record's digest is set to that token's digest, unconditionally — the
770 // digest follows the secret most recently handed to a live child, and
771 // nothing else ever writes it. The record is RE-READ from the store
772 // first: the caller's `record` was read before this await point, and
773 // writing a stale copy back is how a freshly stored digest gets
774 // clobbered by an older one (the defect this replaces: `touch()` used
775 // to re-put its caller's pre-spawn copy, restoring the pre-spawn digest
776 // and 401ing every MCP call the child made).
777 if let Some(minted) = plan.token.as_ref() {
778 let mut updated = self
779 .store()
780 .get_assistant_session(&session_id)
781 .await?
782 .ok_or(AssistantSessionError::NotFound { session_id })?;
783 updated.mcp_token_digest = Some(minted.digest().to_owned());
784 updated.updated_at = Utc::now();
785 self.store().put_assistant_session(updated).await?;
786 }
787 // The grounding pack is rewritten before the child starts, so the
788 // directory the first turn's preamble names holds THIS binary's
789 // documents by the time the agent can read it.
790 let grounding_dir = grounding::directory().map_err(AssistantSessionError::Internal)?;
791 grounding::materialize(&grounding_dir).map_err(AssistantSessionError::Internal)?;
792 let mut built = plan.harness;
793 if prior.is_some() {
794 built = built.with_session_parameter(RESUME_PARAMETER);
795 }
796 let input = spawn_input(composed_prompt, prior.as_deref())?;
797 let spec = AgentRunSpec::new(
798 WorkflowId::new(session_id.as_uuid()),
799 RunId::new_v4(),
800 ActivityId::from_sequence_position(1),
801 1,
802 ACTIVITY_TYPE.to_owned(),
803 input,
804 );
805 // No spawn timeout: the spawn either yields `initialize` or the child
806 // exits, and both are events (RULED 2026-08-29). A child that starts and
807 // then says nothing holds this turn open until it exits or the caller
808 // goes away — at which point the future is dropped and the agent's whole
809 // process group goes with it.
810 let started =
811 built
812 .start(spec)
813 .await
814 .map_err(|error| AssistantSessionError::HarnessFailed {
815 harness: record.harness.clone(),
816 reason: error.to_string(),
817 })?;
818
819 // The registry's own channel, never a fresh one: a socket opened on this
820 // session before the spawn must receive what the spawn produces.
821 let recorder = self.recorder(session_id);
822 let mut started = started;
823 let handle = started.take_first_turn().ok_or_else(|| {
824 AssistantSessionError::Internal(
825 "a freshly started assistant harness had no opening turn to drive".to_owned(),
826 )
827 })?;
828 let live = Arc::new(LiveSession::new(session_id, started, recorder));
829 self.adopt(Arc::clone(&live));
830 // Recorded through the LIVE session's recorder, so the frames a watcher
831 // is already subscribed to include the opening.
832 live.recorder()
833 .record(AssistantSessionEvent::SessionOpened {
834 acp_session_ref: live.acp_session_ref().to_owned(),
835 load_session: live.supports_load_session(),
836 at: Utc::now(),
837 resumed: prior.is_some(),
838 })
839 .await?;
840 live.recorder()
841 .record(AssistantSessionEvent::State {
842 state: AssistantSessionState::Live,
843 reason: None,
844 })
845 .await?;
846 // The agent may advertise its configuration options — the model picker
847 // among them — on the OPENING response rather than as an update. The
848 // pump only streams updates into open turns, so this is the one moment
849 // that advertisement exists; recorded here it reaches the transcript,
850 // the record cache and every watcher exactly as a mid-turn update
851 // would. An unreadable advertisement is recorded verbatim instead —
852 // the same nothing-is-dropped rule the translator applies.
853 if let Some(advertised) = live.take_initial_config_options() {
854 let frame = match frames::config_options(&advertised) {
855 Some(options) => AssistantSessionEvent::ConfigOptions { options },
856 None => AssistantSessionEvent::Raw {
857 turn_id: None,
858 source: "session/new/config_options".to_owned(),
859 value: advertised,
860 },
861 };
862 live.recorder().record(frame).await?;
863 }
864 Ok((live, handle))
865 }
866
867 /// Record the frames that open a turn.
868 ///
869 /// The REQUEST comes first and carries what the operator asked and the
870 /// screen they asked it from — one record, appended before anything the
871 /// harness produces for this turn, so a reloaded conversation is questions
872 /// and answers rather than answers alone.
873 async fn record_turn_start(
874 &self,
875 live: &Arc<LiveSession>,
876 turn_id: &str,
877 context: Option<AssistantTurnContext>,
878 text: &str,
879 command: Option<AssistantCommandInvocation>,
880 composed: &str,
881 ) -> Result<(), AssistantSessionError> {
882 live.recorder()
883 .record(AssistantSessionEvent::Request {
884 turn_id: turn_id.to_owned(),
885 text: text.to_owned(),
886 // An EMPTY context is no context: recording one would put a
887 // blank screen on the record and make the shared context the
888 // harness reads emptier than the last real one.
889 context: context.filter(|context| !prompt::is_empty(context)),
890 command,
891 })
892 .await?;
893 live.recorder()
894 .record(AssistantSessionEvent::TurnStarted {
895 turn_id: turn_id.to_owned(),
896 at: Utc::now(),
897 prompt: composed.to_owned(),
898 })
899 .await
900 .map(drop)
901 }
902
903 /// Refuse a command the harness never advertised, naming what it did.
904 ///
905 /// The record's cache answers first because it is already loaded; only a
906 /// name that is NOT in it costs a full transcript read, and that read is
907 /// what decides. A cache that had fallen behind would otherwise refuse a
908 /// command the agent really does serve — a refusal is the one answer that
909 /// must not be given on stale information.
910 async fn require_advertised(
911 &self,
912 record: &AssistantSessionRecord,
913 command: &AssistantCommandInvocation,
914 ) -> Result<(), AssistantSessionError> {
915 if record
916 .commands
917 .iter()
918 .any(|advertised| advertised.name == command.name)
919 {
920 return Ok(());
921 }
922 let advertised = self.projection(record.session_id).await?.commands;
923 if advertised.iter().any(|entry| entry.name == command.name) {
924 return Ok(());
925 }
926 Err(AssistantSessionError::UnknownCommand {
927 session_id: record.session_id,
928 requested: command.name.clone(),
929 advertised: declared_names(advertised.iter().map(|entry| entry.name.as_str())),
930 })
931 }
932
933 /// The catalogue harness the caller named, or the one they last used.
934 ///
935 /// A caller who names none gets their LAST PICK, and a caller who has never
936 /// picked gets the first catalogue entry that can actually run on this
937 /// machine — availability measured now, because offering an entry this box
938 /// cannot start is how a first message turns into a refusal. If nothing can
939 /// run, the first entry is chosen anyway so the refusal that follows names
940 /// a harness and carries its install hint, rather than being a bare "no".
941 async fn resolve_harness(
942 &self,
943 subject: &str,
944 requested: Option<&str>,
945 ) -> Result<&'static CatalogueHarness, AssistantSessionError> {
946 if let Some(name) = requested {
947 return self
948 .harness_entry(name)
949 .ok_or_else(|| AssistantSessionError::UnknownHarness {
950 requested: name.to_owned(),
951 declared: self.declared_harnesses(),
952 });
953 }
954 if let Some(remembered) = self.last_harness_pick(subject).await?
955 && let Some(harness) = self.harness_entry(&remembered)
956 {
957 return Ok(harness);
958 }
959 self.catalogue()
960 .iter()
961 .find(|entry| entry.available())
962 .or_else(|| self.catalogue().first())
963 .ok_or_else(|| AssistantSessionError::UnknownHarness {
964 requested: "(none named)".to_owned(),
965 declared: self.declared_harnesses(),
966 })
967 }
968
969 /// The catalogue entry `id` names in THIS registry's catalogue, or none.
970 fn harness_entry(&self, id: &str) -> Option<&'static CatalogueHarness> {
971 catalogue::harness_in(self.catalogue(), id)
972 }
973
974 /// Every harness this registry offers, for a refusal that has to say so.
975 fn declared_harnesses(&self) -> String {
976 catalogue::ids_of(self.catalogue())
977 }
978
979 /// The account the caller named, or none when they named none.
980 fn resolve_account(
981 &self,
982 harness: &str,
983 requested: Option<&str>,
984 ) -> Result<Option<&ResolvedAssistantAccount>, AssistantSessionError> {
985 match requested {
986 None => Ok(None),
987 Some(name) => self.resolve_account_by_name(harness, name).map(Some),
988 }
989 }
990
991 /// The named account, or a refusal listing what the harness declares.
992 fn resolve_account_by_name(
993 &self,
994 harness: &str,
995 name: &str,
996 ) -> Result<&ResolvedAssistantAccount, AssistantSessionError> {
997 self.config()
998 .account(harness, name)
999 .ok_or_else(|| AssistantSessionError::UnknownAccount {
1000 harness: harness.to_owned(),
1001 requested: name.to_owned(),
1002 declared: declared_names(
1003 self.config()
1004 .harness(harness)
1005 .into_iter()
1006 .flat_map(|declared| declared.accounts.iter())
1007 .map(|account| account.name.as_str()),
1008 ),
1009 })
1010 }
1011}
1012
1013/// The job input one spawn carries: the prompt, and the prior session when this
1014/// opening is a resume.
1015///
1016/// A JSON OBJECT rather than a bare string because that is how the adapter's
1017/// workspace reads a resume parameter: the prompt is the one field that is
1018/// neither the directory parameter nor the declared session parameter.
1019fn spawn_input(prompt: &str, prior: Option<&str>) -> Result<Payload, AssistantSessionError> {
1020 let mut input = serde_json::Map::new();
1021 input.insert(
1022 PROMPT_PARAMETER.to_owned(),
1023 serde_json::Value::String(prompt.to_owned()),
1024 );
1025 if let Some(prior) = prior {
1026 input.insert(
1027 RESUME_PARAMETER.to_owned(),
1028 serde_json::Value::String(prior.to_owned()),
1029 );
1030 }
1031 let bytes = serde_json::to_vec(&serde_json::Value::Object(input)).map_err(|error| {
1032 AssistantSessionError::Internal(format!("the harness job input is not encodable: {error}"))
1033 })?;
1034 Ok(Payload::new(ContentType::Json, bytes))
1035}
1036
1037/// The declared names, rendered for a refusal.
1038fn declared_names<'name>(names: impl Iterator<Item = &'name str>) -> String {
1039 let rendered: Vec<String> = names.map(|name| format!("`{name}`")).collect();
1040 if rendered.is_empty() {
1041 return "none".to_owned();
1042 }
1043 rendered.join(", ")
1044}
1045
1046/// The job-input field carrying the opening prompt.
1047const PROMPT_PARAMETER: &str = "prompt";
1048/// The job-input field naming the prior ACP session to reload.
1049const RESUME_PARAMETER: &str = "acp_session";
1050/// The activity type stamped on a session's transcript events.
1051const ACTIVITY_TYPE: &str = "assistant.session";
1052/// `source` on a context pushed without a question.
1053const CONTEXT_SOURCE_PUSH: &str = "push";
1054
1055/// Why a freshly created session is dormant.
1056pub const CREATED_AWAITING_FIRST_TURN: &str = "created; the harness starts with the first turn, because ACP opens a conversation by asking \
1057 something";
1058/// Why a session with a gone process that CAN be reloaded is dormant.
1059pub const PROCESS_EXITED_RESUMABLE: &str = "process_exited; the agent advertised `loadSession`, so the next turn reopens this \
1060 conversation";
1061/// Why a session with a gone process that cannot be reloaded is ended.
1062pub const PROCESS_EXITED_ENDED: &str = "process_exited; the agent did not advertise `loadSession`, so its conversation cannot be \
1063 reopened";
1064/// Why a resume was refused.
1065pub const RESUME_REFUSED: &str = "resume_refused: loadSession not advertised — this agent cannot reload a prior conversation, \
1066 and starting a fresh one would silently discard it";
1067/// Why a deleted session ended.
1068pub const DELETED: &str = "deleted by the caller";
1069/// Why a turn on an ended session is refused: ended is terminal. A session the
1070/// caller deleted, or whose agent could not reload it, has no next turn — a
1071/// turn that spawned a fresh agent for it would resurrect a conversation the
1072/// caller had already put down for good, under the same id.
1073pub const ENDED_IS_TERMINAL: &str =
1074 "this session has ended and cannot be turned again; open a new session";
1075/// Why a session is closed rather than ended: the caller put it away, and the
1076/// agent can reload it.
1077pub const CLOSED_RESUMABLE: &str = "closed by the caller; the agent advertised `loadSession`, so a turn taken on this \
1078 conversation reopens it";
1079/// Why a session went dormant when the server stopped.
1080pub const SERVER_STOPPED: &str = "process_exited; the server that held this session stopped";
1081/// The `TurnFailed` message for a turn whose process died before answering.
1082pub const OPEN_TURN_ORPHANED: &str =
1083 "the agent's process ended before this turn finished; nothing further arrives for it";
1084
1085/// Validate a config change against the session's advertisement and produce
1086/// the wire value — the model picker's refusal-by-advertisement, carved out of
1087/// the facade so the facade stays readable.
1088///
1089/// # Errors
1090///
1091/// [`AssistantSessionError::UnknownConfigOption`] for an unadvertised option,
1092/// an unadvertised select choice, or a value of the wrong shape for the
1093/// advertised kind — in every case naming what IS advertised, because the fix
1094/// is always "send what the advertisement says".
1095fn chosen_config_value(
1096 config_options: &[AssistantConfigOption],
1097 session_id: AssistantSessionId,
1098 option_id: &str,
1099 value: &serde_json::Value,
1100) -> Result<AcpConfigValue, AssistantSessionError> {
1101 let advertised_ids = || {
1102 if config_options.is_empty() {
1103 "none".to_owned()
1104 } else {
1105 config_options
1106 .iter()
1107 .map(|option| option.id.as_str())
1108 .collect::<Vec<_>>()
1109 .join(", ")
1110 }
1111 };
1112 let Some(advertised) = config_options.iter().find(|option| option.id == option_id) else {
1113 return Err(AssistantSessionError::UnknownConfigOption {
1114 session_id,
1115 requested: option_id.to_owned(),
1116 advertised: advertised_ids(),
1117 });
1118 };
1119 match (&advertised.value, value) {
1120 (AssistantConfigValue::Select { choices, .. }, serde_json::Value::String(id)) => {
1121 if choices.iter().any(|choice| choice.id == *id) {
1122 Ok(AcpConfigValue::Choice(id.clone()))
1123 } else {
1124 Err(AssistantSessionError::UnknownConfigOption {
1125 session_id,
1126 requested: format!("{option_id} = {id}"),
1127 advertised: choices
1128 .iter()
1129 .map(|choice| choice.id.as_str())
1130 .collect::<Vec<_>>()
1131 .join(", "),
1132 })
1133 }
1134 }
1135 (AssistantConfigValue::Toggle { .. }, serde_json::Value::Bool(flag)) => {
1136 Ok(AcpConfigValue::Toggle(*flag))
1137 }
1138 // A value of the wrong SHAPE for the advertised kind: refused with the
1139 // same variant, because the fix is the same — send what the
1140 // advertisement says this option takes.
1141 (AssistantConfigValue::Select { .. }, _) => {
1142 Err(AssistantSessionError::UnknownConfigOption {
1143 session_id,
1144 requested: format!("{option_id} (a select takes a choice id string)"),
1145 advertised: advertised_ids(),
1146 })
1147 }
1148 (AssistantConfigValue::Toggle { .. }, _) => {
1149 Err(AssistantSessionError::UnknownConfigOption {
1150 session_id,
1151 requested: format!("{option_id} (a toggle takes true or false)"),
1152 advertised: advertised_ids(),
1153 })
1154 }
1155 }
1156}