Skip to main content

oino_core/
runtime.rs

1//! `AgentSessionRuntime` (`agent-session-runtime` + `createAgentSession`):
2//! owns the current [`AgentSession`], replaces it on new/switch/fork/clone/import, and keeps
3//! one client event stream across replacements.
4
5use std::path::{Path, PathBuf};
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::{Arc, Mutex, Weak};
8use std::time::{Duration, Instant};
9
10use futures::StreamExt;
11use oino_ai::{Model, ThinkingLevel};
12use oino_models::resolver::{InitialModelOptions, ScopedModel, find_initial_model};
13use oino_models::virtual_models::branch_selection;
14use oino_session::{
15    CreateOptions, EntryId, EntryKind, LeaseInfo, SessionEntry, SessionError, SessionLease,
16    SessionLockError, SessionManager, clear_takeover_request, read_takeover_request,
17    write_takeover_request,
18};
19use serde_json::json;
20use tokio::task::JoinHandle;
21
22use crate::events::{ModelChangeSource, OinoEvent, ReplaceReason};
23use crate::extensions::api::ExtensionMode;
24use crate::hub::{EventHub, EventStream};
25use crate::services::{Services, ServicesError};
26use crate::session::{AgentSession, SessionConfig, SessionOpError, ToolEntry, lock};
27
28/// Where the session comes from (runtime §4).
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub enum SessionSource {
31    /// A new persisted session.
32    New,
33    /// A new persisted session with an explicit header id (`--session-id`; the id is created
34    /// when no session has it yet).
35    NewWithId(String),
36    /// Open a session file.
37    Open(PathBuf),
38    /// Continue the most recent session of the cwd.
39    ContinueRecent,
40    /// Resume by id (exact, then unique prefix).
41    Resume(String),
42    /// Fork a session file into a new one.
43    Fork(PathBuf),
44    /// Not persisted.
45    InMemory,
46}
47
48/// Tool selection (`--tools`, `-nt`, `-nbt`).
49#[derive(Clone, Debug, Default, PartialEq, Eq)]
50pub enum ToolSelection {
51    /// `defaultTools` setting.
52    #[default]
53    Default,
54    /// Explicit list (`+name`/`-name` entries modify the defaults).
55    List(Vec<String>),
56    /// `--no-tools`.
57    None,
58    /// `--no-builtin-tools`.
59    NoBuiltin,
60}
61
62/// Inputs of [`AgentSessionRuntime::create`].
63#[derive(Clone)]
64pub struct SessionOptions {
65    /// Session source.
66    pub source: SessionSource,
67    /// `--session-dir`.
68    pub session_dir: Option<PathBuf>,
69    /// `--name`.
70    pub name: Option<String>,
71    /// Explicit model (`--model`, resolved by the caller).
72    pub model: Option<Model>,
73    /// Explicit thinking level.
74    pub thinking_level: Option<ThinkingLevel>,
75    /// `--models` scope.
76    pub scoped_models: Vec<ScopedModel>,
77    /// Tool selection.
78    pub tools: ToolSelection,
79    /// `--exclude-tools`.
80    pub exclude_tools: Vec<String>,
81    /// Built-in tools available to the session.
82    pub builtin_tools: Vec<ToolEntry>,
83    /// SDK custom tools.
84    pub custom_tools: Vec<ToolEntry>,
85}
86
87impl std::fmt::Debug for SessionOptions {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.debug_struct("SessionOptions")
90            .field("source", &self.source)
91            .field("tools", &self.tools)
92            .finish_non_exhaustive()
93    }
94}
95
96impl Default for SessionOptions {
97    fn default() -> Self {
98        Self {
99            source: SessionSource::New,
100            session_dir: None,
101            name: None,
102            model: None,
103            thinking_level: None,
104            scoped_models: Vec::new(),
105            tools: ToolSelection::Default,
106            exclude_tools: Vec::new(),
107            builtin_tools: Vec::new(),
108            custom_tools: Vec::new(),
109        }
110    }
111}
112
113/// The `{cancelled}` result of replacements.
114#[derive(Clone, Debug, Default, PartialEq, Eq)]
115pub struct Replaced {
116    /// An extension cancelled the replacement.
117    pub cancelled: bool,
118    /// Text of the forked-from user message (fork `Before`).
119    pub selected_text: Option<String>,
120}
121
122/// Fork position.
123#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
124pub enum ForkPosition {
125    /// Before the user message (its text is returned for editing).
126    #[default]
127    Before,
128    /// At the entry (included).
129    At,
130}
131
132struct Current {
133    session: AgentSession,
134    forward: JoinHandle<()>,
135}
136
137/// Owns the current session. Extension command contexts reach it
138/// through a weak link for `newSession` / `fork` / `switchSession` / `shutdown`.
139pub struct AgentSessionRuntime {
140    core: Arc<RuntimeCore>,
141}
142
143fn old_session(current: &Mutex<Current>) -> AgentSession {
144    lock(current).session.clone()
145}
146
147/// Shared state of an [`AgentSessionRuntime`].
148pub(crate) struct RuntimeCore {
149    services: Arc<Services>,
150    hub: EventHub,
151    current: Mutex<Current>,
152    options: SessionOptions,
153    model_fallback_message: std::sync::OnceLock<String>,
154    shutdown: tokio::sync::watch::Sender<bool>,
155    /// Single-writer lease of the current session (D-LOCK); `None` for in-memory sessions.
156    lease: Mutex<Option<SessionLease>>,
157    /// Bumped whenever `lease` changes; stale takeover watchers exit on a mismatch.
158    lease_epoch: AtomicU64,
159    /// Embedding override of the default takeover behaviour (D-TAKEOVER).
160    takeover_handler: Mutex<Option<TakeoverHandler>>,
161    me: Weak<RuntimeCore>,
162}
163
164impl std::fmt::Debug for AgentSessionRuntime {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        f.debug_struct("AgentSessionRuntime")
167            .finish_non_exhaustive()
168    }
169}
170
171fn session_error(error: impl std::fmt::Display) -> ServicesError {
172    ServicesError::Session(error.to_string())
173}
174
175fn op_error(error: impl std::fmt::Display) -> SessionOpError {
176    SessionOpError::Failed(error.to_string())
177}
178
179/// D-LOCK / D-TAKEOVER timings (spec core.md §2.1): retry window after a certainly-dead
180/// holder (kernel release lag), takeover poll interval, takeover deadline and the holder-side
181/// watcher poll.
182const DEAD_HOLDER_RETRY: Duration = Duration::from_secs(2);
183const TAKEOVER_POLL: Duration = Duration::from_millis(200);
184const TAKEOVER_DEADLINE: Duration = Duration::from_secs(10);
185const TAKEOVER_WATCH_POLL: Duration = Duration::from_millis(500);
186
187/// Overrides the default D-TAKEOVER holder behaviour (`on_takeover`): called instead of
188/// "close this session and keep the runtime usable" — e.g. WP-C7's hub closes its backend
189/// and notifies its client.
190pub type TakeoverHandler =
191    Arc<dyn Fn(LeaseInfo) -> futures::future::BoxFuture<'static, ()> + Send + Sync>;
192
193/// Process identity for lease metadata (D-LOCK): `oino <mode>` holding a pid/host/boot id.
194pub(crate) fn process_lease_info(agent_dir: &Path, mode: ExtensionMode) -> LeaseInfo {
195    let mode = match mode {
196        ExtensionMode::Tui => "tui",
197        ExtensionMode::Rpc => "rpc",
198        ExtensionMode::Json => "json",
199        ExtensionMode::Print => "print",
200        ExtensionMode::Acp => "acp",
201        ExtensionMode::Desktop => "desktop",
202        ExtensionMode::Mobile => "mobile",
203    };
204    LeaseInfo::for_process(&format!("oino {mode}"), &agent_dir.join("state"))
205}
206
207/// The D-TAKEOVER text when a holder does not respond: precise, names the holder.
208fn locked_text(session_id: &str, holder: &LeaseInfo) -> String {
209    format!(
210        "Session {session_id} is open in {} (pid {} on {}). It was asked to close and did not \
211         respond; close it there and try again.",
212        holder.client, holder.pid, holder.host
213    )
214}
215
216/// Wraps a session-manager failure as [`SessionOpError::Failed`].
217fn session_op(error: impl std::fmt::Display) -> SessionOpError {
218    SessionOpError::Failed(error.to_string())
219}
220
221/// Acquires the single-writer lease of `session_file`, taking an unresponsive holder over
222/// (D-LOCK / D-TAKEOVER): (1) a certainly-dead holder gets a ≤2 s retry window (kernel
223/// release lag), (2) a live one is asked through `<file>.takeover` to close — its watcher
224/// disposes the session and releases within the 10 s deadline —, (3) past the deadline the
225/// precise [`SessionOpError::SessionLocked`] names the holder. The kernel lock remains the
226/// arbiter throughout; there is no lease expiry.
227pub(crate) async fn acquire_lease(
228    session_file: &Path,
229    holder: &LeaseInfo,
230    session_id: &str,
231) -> Result<SessionLease, SessionOpError> {
232    let attempt = || SessionLease::acquire(session_file, holder);
233    let mut held_by = match attempt() {
234        Ok(lease) => return Ok(lease),
235        Err(SessionLockError::HeldBy(info)) => info,
236        Err(SessionLockError::Io { path, source }) => {
237            return Err(SessionOpError::Failed(format!(
238                "session lock I/O error on {}: {source}",
239                path.display()
240            )));
241        }
242    };
243    // (1) certainly-dead holder: the kernel releases on death, retry out its lag. A live
244    // holder appearing mid-window falls through to the takeover phase.
245    if held_by.dead() {
246        let deadline = Instant::now() + DEAD_HOLDER_RETRY;
247        while Instant::now() < deadline {
248            tokio::time::sleep(TAKEOVER_POLL).await;
249            match attempt() {
250                Ok(lease) => return Ok(lease),
251                Err(SessionLockError::HeldBy(info)) => {
252                    held_by = info;
253                    if !held_by.dead() {
254                        break;
255                    }
256                }
257                Err(SessionLockError::Io { .. }) => break,
258            }
259        }
260    }
261    // (2) ask the holder to close and reload here (D-TAKEOVER), polling the kernel lock.
262    if write_takeover_request(session_file, holder).is_err() {
263        tracing::warn!(
264            session_file = %session_file.display(),
265            "cannot write the takeover request; polling the lock only"
266        );
267    }
268    let deadline = Instant::now() + TAKEOVER_DEADLINE;
269    loop {
270        if Instant::now() >= deadline {
271            clear_takeover_request(session_file);
272            return Err(SessionOpError::SessionLocked(locked_text(
273                session_id, &held_by,
274            )));
275        }
276        tokio::time::sleep(TAKEOVER_POLL).await;
277        match attempt() {
278            Ok(lease) => {
279                clear_takeover_request(session_file);
280                return Ok(lease);
281            }
282            Err(SessionLockError::HeldBy(info)) => held_by = info,
283            Err(SessionLockError::Io { path, source }) => {
284                clear_takeover_request(session_file);
285                return Err(SessionOpError::Failed(format!(
286                    "session lock I/O error on {}: {source}",
287                    path.display()
288                )));
289            }
290        }
291    }
292}
293
294/// Binds the lease of `manager`'s session file (D-LOCK ownership rule: the runtime holds the
295/// lease of the current session, children hold their own). New-session paths have unique
296/// planned names, so acquisition is uncontended there.
297async fn bind_lease(
298    services: &Services,
299    manager: &SessionManager,
300) -> Result<Option<SessionLease>, SessionOpError> {
301    let Some(file) = manager.session_file().filter(|_| manager.is_persisted()) else {
302        return Ok(None);
303    };
304    let holder = process_lease_info(&services.agent_dir, services.mode);
305    acquire_lease(file, &holder, manager.session_id())
306        .await
307        .map(Some)
308}
309
310/// Opens a file-backed session under its lease (D-TAKEOVER step 4: acquire, open, repair):
311/// the first open resolves migrations and the canonical path, the lease is acquired with
312/// takeover, and the file is re-read + repaired under the lease so nothing the previous
313/// holder appended after the first read is missed.
314async fn open_under_lease(
315    services: &Services,
316    path: &Path,
317    root: &Path,
318    cwd_override: Option<&Path>,
319) -> Result<(SessionManager, Option<SessionLease>), SessionOpError> {
320    let first = SessionManager::open(path, root, cwd_override).map_err(session_op)?;
321    let lease = bind_lease(services, &first).await?;
322    if lease.is_none() {
323        return Ok((first, None));
324    }
325    let file = first.session_file().map(Path::to_path_buf);
326    let manager = match file {
327        Some(file) => SessionManager::open(&file, root, cwd_override).map_err(session_op)?,
328        None => first,
329    };
330    let manager = repaired(manager).map_err(session_op)?;
331    Ok((manager, lease))
332}
333
334/// Wraps a freshly created manager (new/fork sources) with its lease; no repair (empty or
335/// copy-on-write content).
336async fn fresh_under_lease(
337    services: &Services,
338    manager: SessionManager,
339) -> Result<(SessionManager, Option<SessionLease>), SessionOpError> {
340    let lease = bind_lease(services, &manager).await?;
341    Ok((manager, lease))
342}
343
344/// Repairs the interrupted turn of a just-opened session (D-REPAIR, WP-C2): every write
345/// open of an existing session file repairs before the session is used — a torn tail
346/// line is terminated and dangling tool calls are closed with unknown-outcome
347/// toolResults, so `prompt`/`continue_run` always see a balanced transcript. Read-only
348/// paths (listing, fork sources, `export`, `session-read`) never repair.
349fn repaired(mut manager: SessionManager) -> Result<SessionManager, SessionError> {
350    let report = manager.repair_interrupted_turn()?;
351    if report.torn_tail || !report.closed_tool_calls.is_empty() {
352        tracing::info!(
353            session = manager.session_id(),
354            closed_tool_calls = ?report.closed_tool_calls,
355            torn_tail = report.torn_tail,
356            "resume repair appended closers (D-REPAIR)"
357        );
358    }
359    Ok(manager)
360}
361
362/// The model and thinking level a session starts with.
363fn initial_selection(
364    services: &Services,
365    manager: &SessionManager,
366    options: &SessionOptions,
367) -> (Option<Model>, ThinkingLevel, Option<String>) {
368    let context = manager.build_session_context();
369    let has_existing = !context.messages.is_empty();
370    let branch: Vec<SessionEntry> = manager.branch(None).into_iter().cloned().collect();
371    let has_thinking_entry = branch
372        .iter()
373        .any(|e| matches!(e.kind, EntryKind::ThinkingLevelChange { .. }));
374    let models = &services.models;
375    let settings = lock(&services.settings).snapshot();
376    let mut model = options.model.clone();
377    let mut fallback = None;
378    if model.is_none() && has_existing {
379        let refs = crate::session::model_branch_refs(&branch);
380        if let Some(selection) =
381            branch_selection(&refs, |p, id| models.virtual_model(p, id).is_some())
382        {
383            match models.find(&selection.provider, &selection.model_id) {
384                Some(found) if models.has_configured_auth(&found.provider) => model = Some(found),
385                _ => {
386                    fallback = Some(format!(
387                        "Could not restore model {}/{}",
388                        selection.provider, selection.model_id
389                    ));
390                }
391            }
392        }
393    }
394    if model.is_none() {
395        let levels = settings.model_thinking_levels();
396        let initial = find_initial_model(
397            &InitialModelOptions {
398                scoped_models: &options.scoped_models,
399                is_continuing: has_existing,
400                default_provider: settings.default_provider(),
401                default_model_id: settings.default_model(),
402                default_thinking_level: settings.default_thinking_level(),
403                model_thinking_levels: Some(&levels),
404            },
405            models,
406        );
407        model = initial.model;
408        match (&model, fallback.take()) {
409            (None, _) => {
410                fallback = Some(oino_models::auth::guidance::no_models_available_message(
411                    &services.docs_root.join("docs"),
412                ));
413            }
414            (Some(m), Some(message)) => {
415                fallback = Some(format!("{message}. Using {}/{}", m.provider, m.id));
416            }
417            (Some(_), None) => {}
418        }
419    }
420    let default_level = settings
421        .default_thinking_level()
422        .unwrap_or(ThinkingLevel::Medium);
423    let mut thinking = options.thinking_level;
424    if thinking.is_none() && has_existing {
425        thinking = Some(if has_thinking_entry {
426            context.thinking_level.parse().unwrap_or(default_level)
427        } else {
428            default_level
429        });
430    }
431    if thinking.is_none() {
432        thinking = model
433            .as_ref()
434            .and_then(|m| settings.model_thinking_level(&m.provider, &m.id));
435    }
436    let thinking = thinking.unwrap_or(default_level);
437    let thinking = model.as_ref().map_or(ThinkingLevel::Off, |m| {
438        thinking.clamp(&m.available_thinking_levels())
439    });
440    (model, thinking, fallback)
441}
442
443fn tool_names(
444    options: &SessionOptions,
445    settings: &oino_settings::Settings,
446) -> (Vec<String>, Option<Vec<String>>) {
447    let defaults = settings.default_tools().unwrap_or_else(|| {
448        oino_settings::tools::DEFAULT_TOOL_NAMES
449            .iter()
450            .map(|s| (*s).to_owned())
451            .collect()
452    });
453    match &options.tools {
454        ToolSelection::Default => (defaults, None),
455        ToolSelection::None => (Vec::new(), Some(Vec::new())),
456        ToolSelection::NoBuiltin => (Vec::new(), None),
457        ToolSelection::List(entries) => {
458            if entries
459                .iter()
460                .any(|e| oino_settings::tools::is_tool_modifier(e))
461            {
462                (
463                    oino_settings::tools::apply_tool_modifiers(&defaults, entries),
464                    None,
465                )
466            } else {
467                (entries.clone(), Some(entries.clone()))
468            }
469        }
470    }
471}
472
473/// Holder-side takeover watcher (D-TAKEOVER): polls `<session file>.takeover` while the
474/// lease identified by `epoch` is the current one; on a request the session is handed to
475/// the requester through [`RuntimeCore::on_takeover`] (close here, reload there).
476async fn lease_watcher(core: Weak<RuntimeCore>, epoch: u64, session_file: PathBuf) {
477    loop {
478        tokio::time::sleep(TAKEOVER_WATCH_POLL).await;
479        let Some(core) = core.upgrade() else {
480            return; // runtime gone
481        };
482        if core.lease_epoch.load(Ordering::SeqCst) != epoch {
483            return; // lease replaced or released
484        }
485        if let Some(request) = read_takeover_request(&session_file) {
486            core.on_takeover(epoch, &session_file, request.requester)
487                .await;
488            return; // teardown released this lease; the epoch moved on
489        }
490    }
491}
492
493async fn install(
494    services: &Arc<Services>,
495    options: &SessionOptions,
496    hub: &EventHub,
497    runtime: Option<&Weak<RuntimeCore>>,
498    mut manager: SessionManager,
499    reason: &str,
500    previous: Option<PathBuf>,
501) -> Result<(Current, Option<String>), SessionOpError> {
502    let (model, thinking, fallback) = initial_selection(services, &manager, options);
503    let has_existing = !manager.build_session_context().messages.is_empty();
504    let has_thinking_entry = manager
505        .branch(None)
506        .iter()
507        .any(|e| matches!(e.kind, EntryKind::ThinkingLevelChange { .. }));
508    if has_existing {
509        if !has_thinking_entry {
510            manager
511                .append_thinking_level_change(&thinking.to_string())
512                .map_err(op_error)?;
513        }
514    } else {
515        if let Some(model) = &model {
516            manager
517                .append_model_change(&model.provider, &model.id)
518                .map_err(op_error)?;
519        }
520        manager
521            .append_thinking_level_change(&thinking.to_string())
522            .map_err(op_error)?;
523    }
524    let settings = lock(&services.settings).snapshot();
525    let (initial, allowed) = tool_names(options, &settings);
526    let excluded = &options.exclude_tools;
527    let initial: Vec<String> = initial
528        .into_iter()
529        .filter(|n| !excluded.contains(n))
530        .collect();
531    let builtin = if matches!(
532        options.tools,
533        ToolSelection::NoBuiltin | ToolSelection::None
534    ) {
535        Vec::new()
536    } else {
537        options.builtin_tools.clone()
538    };
539    let session = AgentSession::new(SessionConfig {
540        cwd: services.cwd.clone(),
541        agent_dir: services.agent_dir.clone(),
542        docs_root: services.docs_root.clone(),
543        manager,
544        settings: Arc::clone(&services.settings),
545        resources: Arc::clone(&services.resources),
546        models: services.models.clone(),
547        extensions: Arc::clone(&services.extensions),
548        stream: Arc::clone(&services.stream) as Arc<dyn oino_agent::StreamFn>,
549        mode: services.mode,
550        has_ui: services.has_ui,
551        builtin_tools: builtin,
552        custom_tools: options.custom_tools.clone(),
553        initial_active_tools: initial,
554        allowed_tools: allowed,
555        excluded_tools: excluded.clone(),
556        model,
557        thinking_level: thinking,
558        scoped_models: options.scoped_models.clone(),
559        flag_values: services.flag_values.clone(),
560        child: None,
561    });
562    if matches!(options.tools, ToolSelection::Default) {
563        session.restore_tools_from_transcript(&[]);
564    }
565    services.host.attach(&session);
566    session.link_router(&services.host);
567    if let Some(runtime) = runtime {
568        session.link_runtime(runtime.clone());
569    }
570    let mut stream = session.subscribe();
571    let hub = hub.clone();
572    let forward = tokio::spawn(async move {
573        while let Some(event) = stream.next().await {
574            hub.publish(event);
575        }
576    });
577    session.bind_extensions(reason, previous).await;
578    if reason == "startup"
579        && let Some(model) = session.model()
580    {
581        let runner = session.runner();
582        if runner.has_handlers("model_select") {
583            runner
584                .emit(json!({"type": "model_select", "model": model, "source": "restore"}))
585                .await;
586        }
587        session.emit_model_changed(ModelChangeSource::Restore);
588    }
589    Ok((Current { session, forward }, fallback))
590}
591
592/// Resolves the first session manager of `source` together with its lease: write opens of
593/// existing files go through [`open_under_lease`] (acquire → open → repair), fresh sources
594/// through [`fresh_under_lease`].
595async fn source_under_lease(
596    services: &Services,
597    source: &SessionSource,
598    root: &Path,
599) -> Result<(SessionManager, Option<SessionLease>), SessionOpError> {
600    Ok(match source {
601        SessionSource::New => {
602            fresh_under_lease(
603                services,
604                SessionManager::create(&services.cwd, root, CreateOptions::default())
605                    .map_err(op_error)?,
606            )
607            .await?
608        }
609        SessionSource::NewWithId(id) => {
610            fresh_under_lease(
611                services,
612                SessionManager::create(
613                    &services.cwd,
614                    root,
615                    CreateOptions {
616                        id: Some(id.clone()),
617                        parent_session: None,
618                    },
619                )
620                .map_err(op_error)?,
621            )
622            .await?
623        }
624        SessionSource::Open(path) => open_under_lease(services, path, root, None).await?,
625        SessionSource::ContinueRecent => {
626            match SessionManager::find_most_recent(&services.cwd, root).map_err(op_error)? {
627                Some(path) => open_under_lease(services, &path, root, Some(&services.cwd)).await?,
628                None => {
629                    fresh_under_lease(
630                        services,
631                        SessionManager::create(&services.cwd, root, CreateOptions::default())
632                            .map_err(op_error)?,
633                    )
634                    .await?
635                }
636            }
637        }
638        SessionSource::Resume(id) => {
639            match SessionManager::find_by_id(&services.cwd, id, root).map_err(op_error)? {
640                Some(path) => open_under_lease(services, &path, root, None).await?,
641                None => {
642                    return Err(SessionOpError::Failed(format!(
643                        "No session found matching '{id}'"
644                    )));
645                }
646            }
647        }
648        SessionSource::Fork(path) => {
649            fresh_under_lease(
650                services,
651                SessionManager::fork_from(path, &services.cwd, root, CreateOptions::default())
652                    .map_err(op_error)?,
653            )
654            .await?
655        }
656        SessionSource::InMemory => (
657            SessionManager::in_memory(&services.cwd, CreateOptions::default(), Vec::new()),
658            None,
659        ),
660    })
661}
662
663impl AgentSessionRuntime {
664    /// Creates the runtime and its first session; emits `session_start{reason:"startup"}`,
665    /// `resources_discover` and the model restore.
666    ///
667    /// # Errors
668    ///
669    /// Session file failures.
670    pub async fn create(
671        services: Arc<Services>,
672        options: SessionOptions,
673    ) -> Result<Self, ServicesError> {
674        Self::open_session(services, options)
675            .await
676            .map_err(session_error)
677    }
678
679    /// Creates the runtime and its first session keeping [`SessionOpError`] variants
680    /// intact (E-LAZY, core spec §2.2): the hub maps `SessionLocked` (D-TAKEOVER deadline)
681    /// to its `locked` state instead of a generic failure text.
682    ///
683    /// # Errors
684    ///
685    /// Session file and lease failures, with variants preserved.
686    pub async fn create_for_hub(
687        services: Arc<Services>,
688        options: SessionOptions,
689    ) -> Result<Self, SessionOpError> {
690        Self::open_session(services, options).await
691    }
692
693    /// The body of both constructors ([`Self::create`], [`Self::create_for_hub`]).
694    async fn open_session(
695        services: Arc<Services>,
696        options: SessionOptions,
697    ) -> Result<Self, SessionOpError> {
698        let root = services.sessions_root(options.session_dir.as_deref());
699        let (manager, lease) = source_under_lease(&services, &options.source, &root).await?;
700        let hub = EventHub::new();
701        let (current, fallback) =
702            install(&services, &options, &hub, None, manager, "startup", None).await?;
703        let lease_file = lease
704            .as_ref()
705            .map(|lease| lease.session_file().to_path_buf());
706        let core = Arc::new_cyclic(|me: &Weak<RuntimeCore>| RuntimeCore {
707            services: Arc::clone(&services),
708            hub: hub.clone(),
709            current: Mutex::new(current),
710            options,
711            model_fallback_message: std::sync::OnceLock::new(),
712            shutdown: tokio::sync::watch::channel(false).0,
713            lease: Mutex::new(lease),
714            lease_epoch: AtomicU64::new(0),
715            takeover_handler: Mutex::new(None),
716            me: me.clone(),
717        });
718        if let Some(file) = lease_file {
719            let epoch = core.lease_epoch.fetch_add(1, Ordering::SeqCst) + 1;
720            tokio::spawn(lease_watcher(core.me.clone(), epoch, file));
721        }
722        if let Some(fallback) = fallback {
723            let _ = core.model_fallback_message.set(fallback);
724        }
725        core.session().link_runtime(core.me.clone());
726        let runtime = Self { core };
727        if let Some(name) = runtime.core.options.name.clone() {
728            runtime.session().set_session_name(Some(name)).await?;
729        }
730        Ok(runtime)
731    }
732
733    /// Message shown when the model falls back.
734    #[must_use]
735    pub fn model_fallback_message(&self) -> Option<&str> {
736        self.core.model_fallback_message.get().map(String::as_str)
737    }
738
739    /// The services.
740    #[must_use]
741    pub fn services(&self) -> &Arc<Services> {
742        &self.core.services
743    }
744
745    /// Extension settings for frontends (settings hub, modes §4 `_oino/get_settings_schema`,
746    /// `get_extension_settings`, `set_extension_setting`): schema, layered values with masked
747    /// secrets, scoped writes and change notifications.
748    #[must_use]
749    pub fn extension_settings(&self) -> &Arc<crate::extensions::settings::ExtensionSettings> {
750        &self.core.services.extension_settings
751    }
752
753    /// The current session.
754    #[must_use]
755    pub fn session(&self) -> AgentSession {
756        self.core.session()
757    }
758
759    /// Client events across session replacements.
760    #[must_use]
761    pub fn subscribe(&self) -> EventStream {
762        self.core.hub.subscribe()
763    }
764
765    /// Becomes `true` when an extension called `ctx.shutdown()`;
766    /// frontends exit once the session is idle.
767    #[must_use]
768    pub fn shutdown_requested(&self) -> tokio::sync::watch::Receiver<bool> {
769        self.core.shutdown.subscribe()
770    }
771
772    /// Starts a new session.
773    ///
774    /// # Errors
775    ///
776    /// Session file failures.
777    pub async fn new_session(&self, parent: Option<PathBuf>) -> Result<Replaced, SessionOpError> {
778        self.core.new_session(parent).await
779    }
780
781    /// Switches to another session.
782    ///
783    /// # Errors
784    ///
785    /// The file cannot be opened.
786    pub async fn switch_session(
787        &self,
788        path: PathBuf,
789        cwd_override: Option<PathBuf>,
790    ) -> Result<Replaced, SessionOpError> {
791        self.core.switch_session(path, cwd_override).await
792    }
793
794    /// Forks the session.
795    ///
796    /// # Errors
797    ///
798    /// `Invalid entry ID for forking` and session file failures.
799    pub async fn fork(
800        &self,
801        entry: &EntryId,
802        position: ForkPosition,
803    ) -> Result<Replaced, SessionOpError> {
804        self.core
805            .fork_with_reason(entry, position, ReplaceReason::Fork)
806            .await
807    }
808
809    /// Clones the session at its leaf (fork `At` the leaf).
810    ///
811    /// # Errors
812    ///
813    /// As [`AgentSessionRuntime::fork`]; `Nothing to clone` for an empty session.
814    pub async fn clone_session(&self) -> Result<Replaced, SessionOpError> {
815        let leaf = self
816            .session()
817            .with_manager(|m| oino_session::ReadonlySession::leaf_id(m).cloned())
818            .ok_or_else(|| SessionOpError::Failed("Nothing to clone".into()))?;
819        self.core
820            .fork_with_reason(&leaf, ForkPosition::At, ReplaceReason::Clone)
821            .await
822    }
823
824    /// Copies the file into the sessions dir and switches to it.
825    ///
826    /// # Errors
827    ///
828    /// Missing file or copy failure.
829    pub async fn import_jsonl(
830        &self,
831        path: PathBuf,
832        cwd_override: Option<PathBuf>,
833    ) -> Result<Replaced, SessionOpError> {
834        self.core.import_jsonl(path, cwd_override).await
835    }
836
837    /// Installs the embedding's takeover behaviour override (D-TAKEOVER `on_takeover`);
838    /// `None` restores the default (dispose the session, keep the runtime usable with a
839    /// fresh one, publish [`OinoEvent::SessionTakenOver`], ask frontends to exit).
840    pub fn set_takeover_handler(&self, handler: Option<TakeoverHandler>) {
841        *lock(&self.core.takeover_handler) = handler;
842    }
843
844    /// Shuts the current session down (`session_shutdown{reason:"quit"}`).
845    pub async fn dispose(&self) {
846        self.core.teardown("quit", None).await;
847        self.core.hub.close();
848    }
849
850    /// Idle eviction (E-LAZY, core spec §2.2): disposes the session so it returns to a
851    /// record — `session_shutdown{reason:"idle"}` reaches the extensions (guests drop
852    /// their `SessionMap` entry), the lease is released, and
853    /// [`OinoEvent::SessionEvicted`] is published before the client streams end. Unlike
854    /// [`AgentSessionRuntime::dispose`], no fresh session replaces the current one: the
855    /// hub re-opens the file on demand.
856    pub async fn evict(&self) {
857        let session = self.core.session();
858        let session_id = session.session_id();
859        tracing::info!(session_id = %session_id, "idle eviction (E-LAZY): disposing");
860        session
861            .hub()
862            .publish(OinoEvent::SessionEvicted { session_id });
863        self.core.teardown("idle", None).await;
864        self.core.hub.close();
865    }
866
867    /// Hub variant of the D-TAKEOVER holder behaviour: another runner asked this process
868    /// to give the session up. Disposes the session (releasing the lease) without the
869    /// default handler's fresh replacement session, publishes
870    /// [`OinoEvent::SessionTakenOver`] on the session stream and asks frontends to return
871    /// to their session list (`shutdown` watch).
872    pub async fn evict_taken_over(&self, by: LeaseInfo) {
873        let session = self.core.session();
874        tracing::info!(
875            session_id = %session.session_id(),
876            by = %by.describe(),
877            "session taken over (D-TAKEOVER, hub variant)"
878        );
879        session.hub().publish(OinoEvent::SessionTakenOver { by });
880        self.core.teardown("takeover", None).await;
881        self.core.hub.close();
882        self.core.shutdown.send_replace(true);
883    }
884}
885
886impl RuntimeCore {
887    pub(crate) fn session(&self) -> AgentSession {
888        lock(&self.current).session.clone()
889    }
890
891    /// `ctx.shutdown()`.
892    pub(crate) fn request_shutdown(&self) {
893        if !self.shutdown.send_replace(true) {
894            self.session().hub().publish(OinoEvent::ShutdownRequested);
895        }
896    }
897
898    /// D-TAKEOVER holder side: this session was taken over by another runner. Aborts the
899    /// run, emits `session_shutdown{reason:"takeover"}`, disposes the session (releasing
900    /// the lease), publishes [`OinoEvent::SessionTakenOver`], keeps the runtime usable with
901    /// a fresh session and asks frontends to return to their session list.
902    ///
903    /// `epoch` + `session_file` guard against a stale watcher (one tick behind a lease
904    /// swap) taking over the session that replaced its own.
905    pub(crate) async fn on_takeover(
906        self: &Arc<Self>,
907        epoch: u64,
908        session_file: &Path,
909        by: LeaseInfo,
910    ) {
911        {
912            let held = lock(&self.lease);
913            let still_current = self.lease_epoch.load(Ordering::SeqCst) == epoch
914                && held
915                    .as_ref()
916                    .is_some_and(|lease| lease.session_file() == session_file);
917            if !still_current {
918                return; // stale watcher: this lease is no longer ours
919            }
920        }
921        let handler = lock(&self.takeover_handler).clone();
922        if let Some(handler) = handler {
923            handler(by).await;
924            return;
925        }
926        tracing::info!(session_id = %self.session().session_id(), by = %by.describe(), "session taken over (D-TAKEOVER)");
927        self.teardown("takeover", None).await;
928        self.hub.publish(OinoEvent::SessionTakenOver { by });
929        // "Next session": keep the runtime coherent — the taken-over file is gone from here.
930        let (persisted, root) = self.persisted();
931        let manager = if persisted {
932            SessionManager::create(&self.services.cwd, &root, CreateOptions::default())
933        } else {
934            Ok(SessionManager::in_memory(
935                &self.services.cwd,
936                CreateOptions::default(),
937                Vec::new(),
938            ))
939        };
940        if let Ok(manager) = manager {
941            match bind_lease(&self.services, &manager).await {
942                Ok(lease) => {
943                    if self.swap(manager, lease, "new", None).await.is_ok() {
944                        self.replaced(ReplaceReason::New);
945                    }
946                }
947                Err(error) => {
948                    tracing::warn!(%error, "cannot bind a fresh session after takeover");
949                }
950            }
951        }
952        self.shutdown.send_replace(true);
953    }
954
955    async fn before_switch(&self, reason: &str, target: Option<&Path>) -> bool {
956        let runner = self.session().runner();
957        if !runner.has_handlers("session_before_switch") {
958            return false;
959        }
960        let mut payload = json!({"type": "session_before_switch", "reason": reason});
961        if let Some(target) = target {
962            payload["targetSessionFile"] = json!(target.display().to_string());
963        }
964        runner
965            .emit(payload)
966            .await
967            .is_some_and(|r| r.get("cancel") == Some(&serde_json::Value::Bool(true)))
968    }
969
970    async fn teardown(&self, reason: &str, target: Option<&Path>) {
971        let session = self.session();
972        session.abort().await;
973        let runner = session.runner();
974        if runner.has_handlers("session_shutdown") {
975            let mut payload = json!({"type": "session_shutdown", "reason": reason});
976            if let Some(target) = target {
977                payload["targetSessionFile"] = json!(target.display().to_string());
978            }
979            runner.emit(payload).await;
980        }
981        self.services.host.detach(&session.session_id());
982        session.dispose().await;
983        self.release_lease();
984    }
985
986    /// Stores the lease of the (about-to-be) current session (D-LOCK): bumps the epoch,
987    /// retiring any previous watcher, and starts this lease's takeover watcher.
988    fn set_lease(&self, lease: Option<SessionLease>) {
989        let epoch = self.lease_epoch.fetch_add(1, Ordering::SeqCst) + 1;
990        let file = lease
991            .as_ref()
992            .map(|lease| lease.session_file().to_path_buf());
993        if let Some(file) = file {
994            tokio::spawn(lease_watcher(self.me.clone(), epoch, file));
995        }
996        *lock(&self.lease) = lease; // replaces (and releases) any leftover
997    }
998
999    /// Releases the current session's lease (kernel lock, metadata, registry).
1000    fn release_lease(&self) {
1001        self.lease_epoch.fetch_add(1, Ordering::SeqCst);
1002        drop(lock(&self.lease).take());
1003    }
1004
1005    async fn swap(
1006        &self,
1007        manager: SessionManager,
1008        lease: Option<SessionLease>,
1009        reason: &str,
1010        previous: Option<PathBuf>,
1011    ) -> Result<(), SessionOpError> {
1012        let (next, _) = install(
1013            &self.services,
1014            &self.options,
1015            &self.hub,
1016            Some(&self.me),
1017            manager,
1018            reason,
1019            previous,
1020        )
1021        .await?;
1022        next.session
1023            .inherit_frontend_state(&old_session(&self.current));
1024        if reason == "fork" {
1025            next.session
1026                .inherit_checkpoints(&old_session(&self.current));
1027        }
1028        let old = std::mem::replace(&mut *lock(&self.current), next);
1029        old.forward.abort();
1030        self.set_lease(lease);
1031        Ok(())
1032    }
1033
1034    fn replaced(&self, reason: ReplaceReason) {
1035        let session = self.session();
1036        self.hub.publish(OinoEvent::SessionReplaced {
1037            session_file: session.session_file().map(|p| p.display().to_string()),
1038            session_id: session.session_id(),
1039            reason,
1040        });
1041    }
1042
1043    fn persisted(&self) -> (bool, PathBuf) {
1044        self.session()
1045            .with_manager(|m| (m.is_persisted(), m.session_dir().to_path_buf()))
1046    }
1047
1048    /// Starts a new session.
1049    ///
1050    /// # Errors
1051    ///
1052    /// Session file failures.
1053    pub(crate) async fn new_session(
1054        &self,
1055        parent: Option<PathBuf>,
1056    ) -> Result<Replaced, SessionOpError> {
1057        if self.before_switch("new", None).await {
1058            return Ok(Replaced {
1059                cancelled: true,
1060                selected_text: None,
1061            });
1062        }
1063        let previous = self.session().session_file();
1064        let (persisted, root) = self.persisted();
1065        let options = CreateOptions {
1066            id: None,
1067            parent_session: parent,
1068        };
1069        let (manager, lease) = if persisted {
1070            fresh_under_lease(
1071                &self.services,
1072                SessionManager::create(&self.services.cwd, &root, options).map_err(op_error)?,
1073            )
1074            .await?
1075        } else {
1076            (
1077                SessionManager::in_memory(&self.services.cwd, options, Vec::new()),
1078                None,
1079            )
1080        };
1081        let target = manager.session_file().map(Path::to_path_buf);
1082        self.teardown("new", target.as_deref()).await;
1083        self.swap(manager, lease, "new", previous).await?;
1084        self.replaced(ReplaceReason::New);
1085        Ok(Replaced::default())
1086    }
1087
1088    /// Switches to another session.
1089    ///
1090    /// # Errors
1091    ///
1092    /// The file cannot be opened.
1093    pub(crate) async fn switch_session(
1094        &self,
1095        path: PathBuf,
1096        cwd_override: Option<PathBuf>,
1097    ) -> Result<Replaced, SessionOpError> {
1098        if self.before_switch("resume", Some(&path)).await {
1099            return Ok(Replaced {
1100                cancelled: true,
1101                selected_text: None,
1102            });
1103        }
1104        let previous = self.session().session_file();
1105        let (_, root) = self.persisted();
1106        let (manager, lease) =
1107            open_under_lease(&self.services, &path, &root, cwd_override.as_deref()).await?;
1108        let target = manager.session_file().map(Path::to_path_buf);
1109        self.teardown("resume", target.as_deref()).await;
1110        self.swap(manager, lease, "resume", previous).await?;
1111        self.replaced(ReplaceReason::Resume);
1112        Ok(Replaced::default())
1113    }
1114
1115    pub(crate) async fn fork_with_reason(
1116        &self,
1117        entry: &EntryId,
1118        position: ForkPosition,
1119        reason: ReplaceReason,
1120    ) -> Result<Replaced, SessionOpError> {
1121        let session = self.session();
1122        let runner = session.runner();
1123        let position_name = match position {
1124            ForkPosition::Before => "before",
1125            ForkPosition::At => "at",
1126        };
1127        if runner.has_handlers("session_before_fork") {
1128            let result = runner
1129                .emit(json!({"type": "session_before_fork", "entryId": entry.as_str(), "position": position_name}))
1130                .await;
1131            if result.is_some_and(|r| r.get("cancel") == Some(&serde_json::Value::Bool(true))) {
1132                return Ok(Replaced {
1133                    cancelled: true,
1134                    selected_text: None,
1135                });
1136            }
1137        }
1138        let selected = session
1139            .with_manager(|m| m.entry(entry).cloned())
1140            .ok_or_else(|| SessionOpError::Failed("Invalid entry ID for forking".into()))?;
1141        let (leaf, selected_text) = match position {
1142            ForkPosition::At => (Some(selected.id.clone()), None),
1143            ForkPosition::Before => match selected.message() {
1144                Some(oino_ai::AgentMessage::User(user)) => {
1145                    (selected.parent_id.clone(), Some(user.content.text("\n")))
1146                }
1147                _ => {
1148                    return Err(SessionOpError::Failed(
1149                        "Invalid entry ID for forking".into(),
1150                    ));
1151                }
1152            },
1153        };
1154        let previous = session.session_file();
1155        let mut manager = session.with_manager(|m| m.clone());
1156        match &leaf {
1157            None => manager
1158                .new_session(CreateOptions {
1159                    id: None,
1160                    parent_session: previous.clone(),
1161                })
1162                .map_err(op_error)?,
1163            Some(leaf) => {
1164                manager.branch_into_new_session(leaf).map_err(op_error)?;
1165            }
1166        }
1167        let (manager, lease) = fresh_under_lease(&self.services, manager).await?;
1168        let target = manager.session_file().map(Path::to_path_buf);
1169        self.teardown("fork", target.as_deref()).await;
1170        self.swap(manager, lease, "fork", previous).await?;
1171        self.replaced(reason);
1172        Ok(Replaced {
1173            cancelled: false,
1174            selected_text,
1175        })
1176    }
1177
1178    /// Copies the file into the sessions dir and switches to it.
1179    ///
1180    /// # Errors
1181    ///
1182    /// Missing file or copy failure.
1183    pub(crate) async fn import_jsonl(
1184        &self,
1185        path: PathBuf,
1186        cwd_override: Option<PathBuf>,
1187    ) -> Result<Replaced, SessionOpError> {
1188        if !path.exists() {
1189            return Err(SessionOpError::Failed(format!(
1190                "File not found: {}",
1191                path.display()
1192            )));
1193        }
1194        let (_, root) = self.persisted();
1195        let dir = oino_session::manager::cwd_session_dir(&root, &self.services.cwd);
1196        tokio::fs::create_dir_all(&dir).await.map_err(op_error)?;
1197        let destination = dir.join(path.file_name().unwrap_or_default());
1198        if destination != path {
1199            tokio::fs::copy(&path, &destination)
1200                .await
1201                .map_err(op_error)?;
1202        }
1203        let result = self.switch_session(destination, cwd_override).await?;
1204        if !result.cancelled {
1205            self.replaced(ReplaceReason::Import);
1206        }
1207        Ok(result)
1208    }
1209}