1use 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#[derive(Clone, Debug, PartialEq, Eq)]
30pub enum SessionSource {
31 New,
33 NewWithId(String),
36 Open(PathBuf),
38 ContinueRecent,
40 Resume(String),
42 Fork(PathBuf),
44 InMemory,
46}
47
48#[derive(Clone, Debug, Default, PartialEq, Eq)]
50pub enum ToolSelection {
51 #[default]
53 Default,
54 List(Vec<String>),
56 None,
58 NoBuiltin,
60}
61
62#[derive(Clone)]
64pub struct SessionOptions {
65 pub source: SessionSource,
67 pub session_dir: Option<PathBuf>,
69 pub name: Option<String>,
71 pub model: Option<Model>,
73 pub thinking_level: Option<ThinkingLevel>,
75 pub scoped_models: Vec<ScopedModel>,
77 pub tools: ToolSelection,
79 pub exclude_tools: Vec<String>,
81 pub builtin_tools: Vec<ToolEntry>,
83 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
115pub struct Replaced {
116 pub cancelled: bool,
118 pub selected_text: Option<String>,
120}
121
122#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
124pub enum ForkPosition {
125 #[default]
127 Before,
128 At,
130}
131
132struct Current {
133 session: AgentSession,
134 forward: JoinHandle<()>,
135}
136
137pub struct AgentSessionRuntime {
140 core: Arc<RuntimeCore>,
141}
142
143fn old_session(current: &Mutex<Current>) -> AgentSession {
144 lock(current).session.clone()
145}
146
147pub(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 lease: Mutex<Option<SessionLease>>,
157 lease_epoch: AtomicU64,
159 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
179const 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
187pub type TakeoverHandler =
191 Arc<dyn Fn(LeaseInfo) -> futures::future::BoxFuture<'static, ()> + Send + Sync>;
192
193pub(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
207fn 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
216fn session_op(error: impl std::fmt::Display) -> SessionOpError {
218 SessionOpError::Failed(error.to_string())
219}
220
221pub(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 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 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
294async 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
310async 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
334async 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
344fn 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
362fn 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
473async 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; };
482 if core.lease_epoch.load(Ordering::SeqCst) != epoch {
483 return; }
485 if let Some(request) = read_takeover_request(&session_file) {
486 core.on_takeover(epoch, &session_file, request.requester)
487 .await;
488 return; }
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
592async 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 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 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 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 #[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 #[must_use]
741 pub fn services(&self) -> &Arc<Services> {
742 &self.core.services
743 }
744
745 #[must_use]
749 pub fn extension_settings(&self) -> &Arc<crate::extensions::settings::ExtensionSettings> {
750 &self.core.services.extension_settings
751 }
752
753 #[must_use]
755 pub fn session(&self) -> AgentSession {
756 self.core.session()
757 }
758
759 #[must_use]
761 pub fn subscribe(&self) -> EventStream {
762 self.core.hub.subscribe()
763 }
764
765 #[must_use]
768 pub fn shutdown_requested(&self) -> tokio::sync::watch::Receiver<bool> {
769 self.core.shutdown.subscribe()
770 }
771
772 pub async fn new_session(&self, parent: Option<PathBuf>) -> Result<Replaced, SessionOpError> {
778 self.core.new_session(parent).await
779 }
780
781 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 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 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 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 pub fn set_takeover_handler(&self, handler: Option<TakeoverHandler>) {
841 *lock(&self.core.takeover_handler) = handler;
842 }
843
844 pub async fn dispose(&self) {
846 self.core.teardown("quit", None).await;
847 self.core.hub.close();
848 }
849
850 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 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 pub(crate) fn request_shutdown(&self) {
893 if !self.shutdown.send_replace(true) {
894 self.session().hub().publish(OinoEvent::ShutdownRequested);
895 }
896 }
897
898 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; }
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 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 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; }
998
999 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 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 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 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}