Skip to main content

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}