use crate::skills::keybinds::KeybindRegistry;
use crate::skills::registry::CommandRegistry;
use crate::{latest_session, resolve_session, EngineHost, HostOpts, Result, Runtime, Session};
use std::sync::Arc;
use tokio::sync::RwLock;
pub struct EngineOpts {
pub continue_session: Option<Option<String>>,
pub system: Option<String>,
pub prompt_manifest: Option<std::path::PathBuf>,
pub profile: Option<String>,
pub no_extensions: bool,
}
pub struct BackgroundTasks {
watcher_shutdown: Arc<std::sync::atomic::AtomicBool>,
watcher_task: tokio::task::JoinHandle<()>,
socket_shutdown: Arc<std::sync::atomic::AtomicBool>,
socket_task: tokio::task::JoinHandle<()>,
#[allow(dead_code)] session_socket_path: String,
session_id: String,
hook_bus: Arc<crate::extensions::hooks::HookBus>,
#[allow(dead_code)]
log_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
}
impl BackgroundTasks {
pub fn shutdown(&self) {
self.watcher_shutdown
.store(true, std::sync::atomic::Ordering::Release);
self.socket_shutdown
.store(true, std::sync::atomic::Ordering::Release);
crate::events::registry::unregister_session(&self.session_id);
if let Ok(handle) = tokio::runtime::Handle::try_current() {
let hook_bus = Arc::clone(&self.hook_bus);
let session_id = self.session_id.clone();
handle.spawn(async move {
hook_bus.clear_session_injection(&session_id).await;
});
}
}
}
impl Drop for BackgroundTasks {
fn drop(&mut self) {
self.watcher_shutdown
.store(true, std::sync::atomic::Ordering::Relaxed);
self.socket_shutdown
.store(true, std::sync::atomic::Ordering::Relaxed);
self.watcher_task.abort();
self.socket_task.abort();
}
}
pub struct EngineBoot {
pub runtime: Runtime,
pub config: crate::SynapsConfig,
pub no_extensions: bool,
pub session: Session,
pub api_messages: Vec<crate::SharedMessage>,
pub total_input_tokens: u64,
pub total_output_tokens: u64,
pub session_cost: f64,
pub continued: bool,
pub continue_info: Option<ContinueInfo>,
pub registry: Arc<CommandRegistry>,
pub keybind_registry: Arc<std::sync::RwLock<KeybindRegistry>>,
pub mcp_server_count: usize,
pub system_prompt_path: std::path::PathBuf,
pub ext_manager: Arc<RwLock<crate::extensions::manager::ExtensionManager>>,
pub background: BackgroundTasks,
}
pub struct ContinueInfo {
pub session_id: String,
pub resolved_via: Option<String>, pub query: String,
pub compaction_notice: Option<String>,
}
pub async fn boot(opts: EngineOpts) -> Result<EngineBoot> {
let host = EngineHost::boot_and_install(HostOpts {
profile: opts.profile.clone(),
no_extensions: opts.no_extensions,
})
.await?;
let mut runtime = host.foreground_runtime().await?;
let config: crate::SynapsConfig = (**host.config()).clone();
let sb = resolve_session_and_prompt(
&mut runtime,
&opts.continue_session,
opts.system.as_deref(),
opts.prompt_manifest.as_deref(),
)?;
let registry = Arc::clone(host.command_registry());
let keybind_registry = Arc::clone(host.keybind_registry());
let mcp_server_count = host.mcp_server_count();
let system_prompt_path = crate::config::resolve_read_path("system.md");
let background = spawn_session_background(&runtime, &sb.session)?;
finish_session_setup(&mut runtime, &config, &sb.session, None, IndexRecord::Start);
let ext_manager = Arc::clone(host.ext_manager());
if mcp_server_count > 0 {
tracing::info!(
"{} MCP servers available (use connect_mcp_server to activate)",
mcp_server_count
);
}
Ok(EngineBoot {
runtime,
config,
no_extensions: opts.no_extensions,
session: sb.session,
api_messages: sb.api_messages,
total_input_tokens: sb.total_input_tokens,
total_output_tokens: sb.total_output_tokens,
session_cost: sb.session_cost,
continued: sb.continued,
continue_info: sb.continue_info,
registry,
keybind_registry,
mcp_server_count,
system_prompt_path,
ext_manager,
background,
})
}
pub(crate) fn resolve_session_and_prompt(
runtime: &mut Runtime,
continue_session: &Option<Option<String>>,
system: Option<&str>,
prompt_manifest: Option<&std::path::Path>,
) -> Result<SessionBootResult> {
let sb = resolve_or_create_session(runtime, continue_session)?;
runtime.set_session_id(Some(sb.session.id.clone()));
runtime.reset_context_continuation(&sb.session.id, &sb.api_messages);
let legacy_prompt = crate::config::resolve_system_prompt(system);
if let Some(path) = prompt_manifest {
let raw = std::fs::read_to_string(path)
.map_err(|_| crate::RuntimeError::Config("prompt manifest is unavailable".into()))?;
let manifest = agent_core::prompt::PromptManifest::parse(&raw)
.map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
let registry = manifest
.registry(path.parent())
.map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
let model = crate::orchestration::canonical_foreground_identity(runtime.model())
.map_err(|e| crate::RuntimeError::Config(format!("invalid foreground model: {e}")))?;
let context = agent_core::prompt::SelectionContext::new(model.clone(), None)
.map_err(|e| crate::RuntimeError::Config(e.to_string()))?;
let catalog = crate::orchestration::OrchestrationRuntime::trusted_catalog(
&model,
manifest.delegation_catalog_candidates(),
)
.map_err(|error| crate::RuntimeError::Config(error.into()))?;
let delegation_policy = manifest
.delegation_policy(model.clone(), &catalog)
.map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
let delegation_policy_digest = delegation_policy.as_ref().map(|policy| policy.digest());
if let Some(policy) = delegation_policy {
runtime.install_orchestration(Arc::new(
crate::orchestration::OrchestrationRuntime::new(policy),
));
} else {
runtime.install_orchestration(Arc::new(
crate::orchestration::OrchestrationRuntime::baseline(model.clone(), 8, 64)
.map_err(|error| crate::RuntimeError::Config(error.into()))?,
));
}
let user = system
.map(|_| {
agent_core::prompt::resolved_system_prompt_as_user_module(legacy_prompt.clone())
})
.transpose()
.map_err(|e| crate::RuntimeError::Config(e.to_string()))?;
let stack =
agent_core::prompt::compile_prompt_stack(&manifest, ®istry, &context, user.clone())
.map_err(|e| {
crate::RuntimeError::Config(format!("invalid prompt manifest: {e}"))
})?;
runtime
.apply_prompt_stack(stack)
.map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
runtime.retain_prompt_reload_source(
path.to_path_buf(),
context,
user,
delegation_policy_digest,
);
} else {
let foreground = crate::orchestration::canonical_foreground_identity(runtime.model())
.map_err(|e| crate::RuntimeError::Config(format!("invalid foreground model: {e}")))?;
runtime.install_orchestration(Arc::new(
crate::orchestration::OrchestrationRuntime::baseline(foreground, 8, 64)
.map_err(|error| crate::RuntimeError::Config(error.into()))?,
));
runtime.set_system_prompt(legacy_prompt);
}
Ok(sb)
}
pub(crate) fn spawn_session_background(
runtime: &Runtime,
session: &Session,
) -> Result<BackgroundTasks> {
let watcher_shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
let watcher_task = {
let inbox_dir = crate::config::base_dir().join("inbox");
let event_queue = runtime.event_queue().clone();
let shutdown = watcher_shutdown.clone();
tokio::spawn(async move {
crate::events::watch_inbox(inbox_dir, event_queue, shutdown).await;
})
};
let abort_tasks = |ws: &Arc<std::sync::atomic::AtomicBool>,
wt: &tokio::task::JoinHandle<()>| {
ws.store(true, std::sync::atomic::Ordering::Relaxed);
wt.abort();
};
let socket_shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
let session_socket_path = crate::events::registry::socket_path_for_session(&session.id);
let socket_task = crate::events::socket::listen_session_socket(
session_socket_path.clone(),
runtime.event_queue().clone(),
socket_shutdown.clone(),
);
let session_registration = crate::events::registry::SessionRegistration {
kind: crate::events::registry::REGISTRATION_KIND.to_string(),
session_id: session.id.clone(),
name: session.name.clone(),
socket_path: session_socket_path.clone(),
pid: std::process::id(),
started_at: chrono::Utc::now(),
};
if let Err(e) = crate::events::registry::register_session(&session_registration) {
abort_tasks(&watcher_shutdown, &watcher_task);
socket_shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
socket_task.abort();
return Err(crate::core::error::RuntimeError::Session(format!(
"failed to register session {}: {}",
session_registration.session_id, e
)));
}
Ok(BackgroundTasks {
watcher_shutdown,
watcher_task,
socket_shutdown,
socket_task,
session_socket_path,
session_id: session.id.clone(),
hook_bus: Arc::clone(runtime.hook_bus()),
log_guard: None,
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum IndexRecord {
Start,
Skip,
}
pub(crate) fn finish_session_setup(
runtime: &mut Runtime,
config: &crate::SynapsConfig,
session: &Session,
cwd: Option<std::path::PathBuf>,
index_record: IndexRecord,
) {
runtime.set_turn_budget(crate::runtime::budget::TurnBudget::from_config(
crate::runtime::budget::TurnRole::Foreground,
&config.turn_budgets,
));
if index_record == IndexRecord::Start {
let mut index_record =
crate::core::session_index::SessionIndexRecord::start(&session.id);
index_record.model = Some(session.model.clone());
index_record.profile = crate::core::config::get_profile();
index_record.cwd = cwd.or_else(|| std::env::current_dir().ok());
if let Err(err) = crate::core::session_index::append_record(&index_record) {
tracing::warn!("failed to append session start index record: {}", err);
}
}
}
pub(crate) struct SessionBootResult {
pub(crate) session: Session,
pub(crate) api_messages: Vec<crate::SharedMessage>,
pub(crate) total_input_tokens: u64,
pub(crate) total_output_tokens: u64,
pub(crate) session_cost: f64,
pub(crate) continued: bool,
pub(crate) continue_info: Option<ContinueInfo>,
}
pub fn lock_session(
id: &str,
continued: bool,
kind: &str,
) -> Result<Option<agent_core::session_lock::SessionLock>> {
let dir = agent_core::session_lock::sessions_dir();
let holder = agent_core::session_lock::LockHolder {
pid: std::process::id(),
kind: kind.to_string(),
};
match agent_core::session_lock::SessionLock::try_acquire(&dir, id, holder) {
Ok(lock) => Ok(Some(lock)),
Err(
e @ (agent_core::session_lock::SessionLockError::Held { .. }
| agent_core::session_lock::SessionLockError::CompactedInto { .. }),
) if continued => Err(crate::RuntimeError::Session(e.to_string())),
Err(e) => {
tracing::warn!(session = %id, "session lock: {e}");
Ok(None)
}
}
}
pub async fn recover_turn_draft(conv: &mut crate::engine::session::ConversationState) {
let dir = agent_core::session_lock::sessions_dir();
let id = conv.session.id.clone();
let read = {
let (dir, id) = (dir.clone(), id.clone());
tokio::task::spawn_blocking(move || {
agent_core::core::session_draft::read_turn_draft(&dir, &id)
})
.await
.map_err(std::io::Error::other)
.and_then(|r| r)
};
let recovered = match read {
Ok(None) => return,
Ok(Some(draft)) => {
crate::engine::interrupt::recover_crashed_turn(&mut conv.api_messages, &draft)
}
Err(e) => {
tracing::warn!(session = %id, "unreadable turn draft, removing it: {e}");
false
}
};
if recovered {
tracing::warn!(
session = %id,
"session was interrupted mid-turn by an unexpected stop; recovered"
);
if !conv.save().await {
tracing::warn!(
session = %id,
"could not save the recovered history; keeping the turn draft"
);
return;
}
} else {
tracing::info!(session = %id, "removing a stale turn draft");
}
let removed = tokio::task::spawn_blocking(move || {
agent_core::core::session_draft::remove_turn_draft(&dir, &id)
})
.await;
if let Ok(Err(e)) | Err(e) = removed.map_err(std::io::Error::other) {
tracing::warn!(session = %conv.session.id, "failed to remove turn draft: {e}");
}
}
fn resolve_or_create_session(
runtime: &mut Runtime,
continue_session: &Option<Option<String>>,
) -> Result<SessionBootResult> {
match continue_session {
Some(ref maybe_id) => {
let raw_session = match maybe_id {
Some(ref id) => resolve_session(id).map_err(|e| {
crate::error::RuntimeError::Tool(format!(
"Failed to load session '{}': {}",
id, e
))
})?,
None => latest_session().map_err(|e| {
crate::error::RuntimeError::Tool(format!("No sessions to continue: {}", e))
})?,
};
let resolved = agent_core::session::follow_compaction_chain(raw_session)
.map_err(|e| crate::error::RuntimeError::Session(e.to_string()))?;
let compaction_notice = resolved.compaction_notice;
let mut session = resolved.session;
runtime.set_model(session.model.clone());
if let Some(clamp) = runtime.restore_session_reasoning(&session.thinking_level) {
tracing::warn!(
from = %clamp.from,
to = %clamp.to,
model = %session.model,
"saved session thinking level not supported by model; clamped"
);
session.thinking_level = runtime.thinking_level().to_string();
}
if let Some(ref sp) = session.system_prompt {
runtime.set_system_prompt(sp.clone());
}
let continue_info = {
let (resolved_via, query) = match maybe_id {
Some(ref q) => {
let via = if *q != session.id {
if crate::chain::load_chain(q).is_ok() {
Some("chain".to_string())
} else if agent_core::session::find_session_by_name(q).is_ok() {
Some("name".to_string())
} else if compaction_notice.is_some() {
Some("compacted".to_string())
} else {
None
}
} else {
None
};
(via, q.clone())
}
None => {
let via = compaction_notice.as_ref().map(|_| "compacted".to_string());
(via, session.id.clone())
}
};
if maybe_id.is_some() || compaction_notice.is_some() {
Some(ContinueInfo {
session_id: session.id.clone(),
resolved_via,
query,
compaction_notice: compaction_notice.clone(),
})
} else {
None
}
};
if crate::engine::interrupt::migrate_legacy_abort_context(
&mut session.api_messages,
&mut session.abort_context,
) {
tracing::info!(
session = %session.id,
"migrated a legacy abort-context recap to an interruption marker"
);
}
Ok(SessionBootResult {
api_messages: session.api_messages.clone(),
total_input_tokens: session.total_input_tokens,
total_output_tokens: session.total_output_tokens,
session_cost: session.session_cost,
continued: true,
continue_info,
session,
})
}
None => {
let session = Session::new(
runtime.model(),
runtime.thinking_level(),
runtime.system_prompt(),
);
Ok(SessionBootResult {
session,
api_messages: Vec::new(),
total_input_tokens: 0,
total_output_tokens: 0,
session_cost: 0.0,
continued: false,
continue_info: None,
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use agent_core::reasoning::ReasoningLevel;
async fn saved_session(first: &str) -> Session {
let mut session = Session::new("claude-sonnet-4-5", "low", None);
session.api_messages = vec![std::sync::Arc::new(
serde_json::json!({"role": "user", "content": first}),
)];
session.save().await.unwrap();
session
}
#[tokio::test]
#[serial_test::serial(synaps_base_dir)]
async fn loading_never_folds_or_touches_the_draft() {
use agent_core::core::session_draft::{read_turn_draft, write_turn_draft, TurnDraft};
let _base = crate::test_env::BaseDirGuard::new();
let dir = agent_core::session_lock::sessions_dir();
let session = saved_session("do X").await;
let draft = TurnDraft {
base_len: 1,
partial_text: "partial".into(),
};
write_turn_draft(&dir, &session.id, &draft).unwrap();
let mut runtime = Runtime::new_headless();
let sb = resolve_or_create_session(&mut runtime, &Some(Some(session.id.clone()))).unwrap();
assert_eq!(sb.api_messages.len(), 1, "history exactly as saved");
assert_eq!(
read_turn_draft(&dir, &session.id).unwrap(),
Some(draft),
"the draft is left for the lock holder"
);
}
#[tokio::test]
#[serial_test::serial(synaps_base_dir)]
async fn the_lock_holder_recovers_saves_then_removes_the_draft() {
use agent_core::core::session_draft::{read_turn_draft, write_turn_draft, TurnDraft};
let _base = crate::test_env::BaseDirGuard::new();
let dir = agent_core::session_lock::sessions_dir();
let session = saved_session("do X").await;
let id = session.id.clone();
write_turn_draft(
&dir,
&id,
&TurnDraft {
base_len: 1,
partial_text: "partial".into(),
},
)
.unwrap();
let mut conv = crate::engine::session::ConversationState::from_resumed(session);
recover_turn_draft(&mut conv).await;
assert_eq!(conv.api_messages.len(), 3, "partial reply + marker");
let on_disk = Session::load(&id).unwrap();
assert_eq!(on_disk.api_messages, conv.api_messages, "saved before removal");
assert_eq!(read_turn_draft(&dir, &id).unwrap(), None, "draft removed");
recover_turn_draft(&mut conv).await;
assert_eq!(conv.api_messages.len(), 3);
}
#[tokio::test]
#[serial_test::serial(synaps_base_dir)]
async fn an_unreadable_draft_is_removed_and_history_kept() {
let _base = crate::test_env::BaseDirGuard::new();
let dir = agent_core::session_lock::sessions_dir();
let session = saved_session("do X").await;
let path = dir.join(format!("{}.turn", session.id));
std::fs::write(&path, b"{not json").unwrap();
let mut conv = crate::engine::session::ConversationState::from_resumed(session);
recover_turn_draft(&mut conv).await;
assert_eq!(conv.api_messages.len(), 1);
assert!(!path.exists());
}
#[cfg(unix)]
#[tokio::test]
#[serial_test::serial(synaps_base_dir)]
async fn a_failed_recovery_save_keeps_the_draft() {
use agent_core::core::session_draft::{read_turn_draft, write_turn_draft, TurnDraft};
let _base = crate::test_env::BaseDirGuard::new();
let dir = agent_core::session_lock::sessions_dir();
let session = saved_session("do X").await;
let id = session.id.clone();
let draft = TurnDraft {
base_len: 1,
partial_text: "partial".into(),
};
write_turn_draft(&dir, &id, &draft).unwrap();
let snapshot = dir.join(format!("{id}.json"));
std::fs::remove_file(&snapshot).unwrap();
std::fs::create_dir(&snapshot).unwrap();
std::fs::write(snapshot.join("occupied"), b"x").unwrap();
let mut conv = crate::engine::session::ConversationState::from_resumed(session);
recover_turn_draft(&mut conv).await;
assert_eq!(read_turn_draft(&dir, &id).unwrap(), Some(draft), "draft kept");
}
#[tokio::test]
#[serial_test::serial(synaps_base_dir)]
async fn boot_hosts_refuse_to_continue_a_session_held_elsewhere() {
let _base = crate::test_env::BaseDirGuard::new();
let held = lock_session("20260930-000000-held", true, "tui").unwrap();
assert!(held.is_some());
let err = lock_session("20260930-000000-held", true, "rpc").unwrap_err();
assert!(err.to_string().contains("20260930-000000-held"), "{err}");
assert!(lock_session("20260930-000000-held", false, "rpc").unwrap().is_none());
drop(held);
assert!(lock_session("20260930-000000-held", true, "rpc").unwrap().is_some());
let mut old = Session::new("claude-sonnet-4-5", "low", None);
old.api_messages = vec![std::sync::Arc::new(
serde_json::json!({"role": "user", "content": "before compaction"}),
)];
old.compacted_into = Some("20260930-000001-next".into());
old.save().await.unwrap();
let err = lock_session(&old.id, true, "rpc").unwrap_err();
assert!(err.to_string().contains("20260930-000001-next"), "{err}");
}
#[test]
fn continue_path_restores_thinking_level_from_session() {
let mut runtime = Runtime::new_headless();
let thinking_level_str = "ultra";
if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
runtime.set_reasoning_level_explicit(level);
}
assert_eq!(
runtime.reasoning_level(),
ReasoningLevel::Ultra,
"thinking level must be restored from session on --continue"
);
assert!(
runtime.is_reasoning_explicit(),
"restored thinking level must be marked explicit so set_model won't overwrite it"
);
}
#[test]
fn continue_path_restores_max_level() {
let mut runtime = Runtime::new_headless();
let thinking_level_str = "max";
if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
runtime.set_reasoning_level_explicit(level);
}
assert_eq!(runtime.reasoning_level(), ReasoningLevel::Max);
}
#[test]
fn continue_path_restores_off_level() {
let mut runtime = Runtime::new_headless();
let thinking_level_str = "off";
if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
runtime.set_reasoning_level_explicit(level);
}
assert_eq!(runtime.reasoning_level(), ReasoningLevel::Off);
}
#[test]
fn default_config_is_dark_no_checkpoint_tool_legacy_binding() {
let runtime = Runtime::new_headless();
assert!(
!runtime.memory_backend_exclusive(),
"default config must NOT be exclusive (Axel); it must be legacy"
);
let tools = runtime.tools_shared();
let registry = tools.blocking_read();
let has_checkpoint = registry
.iter_tools_sorted()
.iter()
.any(|t| t.name() == "context_checkpoint");
assert!(
!has_checkpoint,
"context_checkpoint must not appear in the default tool set"
);
}
}