Skip to main content

agent_engine/engine/
setup.rs

1//! Engine setup — boot sequence shared by TUI and headless modes.
2//!
3//! Extracts the initialization logic that was previously inlined in
4//! chatui/mod.rs so both renderers can use the same boot path.
5
6use crate::skills::keybinds::KeybindRegistry;
7use crate::skills::registry::CommandRegistry;
8use crate::{latest_session, resolve_session, EngineHost, HostOpts, Result, Runtime, Session};
9use std::sync::Arc;
10use tokio::sync::RwLock;
11
12/// Options for engine boot.
13pub struct EngineOpts {
14    pub continue_session: Option<Option<String>>,
15    pub system: Option<String>,
16    pub prompt_manifest: Option<std::path::PathBuf>,
17    /// Honoured by the first `boot` in a process only: the `EngineHost` is
18    /// built once and later boots reuse it, profile included.
19    pub profile: Option<String>,
20    pub no_extensions: bool,
21}
22
23/// Background tasks spawned during boot. Aborts on drop.
24pub struct BackgroundTasks {
25    watcher_shutdown: Arc<std::sync::atomic::AtomicBool>,
26    watcher_task: tokio::task::JoinHandle<()>,
27    socket_shutdown: Arc<std::sync::atomic::AtomicBool>,
28    socket_task: tokio::task::JoinHandle<()>,
29    #[allow(dead_code)] // stored for potential future use (e.g. reconnect)
30    session_socket_path: String,
31    session_id: String,
32    /// Hook bus the session's `on_session_start` injection lives on; cleared
33    /// at shutdown so a long-lived process does not accumulate stale keys.
34    hook_bus: Arc<crate::extensions::hooks::HookBus>,
35    /// File-appender flush guard. Holding this for the lifetime of the
36    /// renderer keeps the non-blocking log writer's background thread
37    /// alive — without it, log lines emitted after `boot()` returns can
38    /// be silently dropped before they reach disk. Dropped last when
39    /// BackgroundTasks drops.
40    #[allow(dead_code)]
41    log_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
42}
43
44impl BackgroundTasks {
45    /// Signal all tasks to stop and unregister the session.
46    pub fn shutdown(&self) {
47        self.watcher_shutdown
48            .store(true, std::sync::atomic::Ordering::Release);
49        self.socket_shutdown
50            .store(true, std::sync::atomic::Ordering::Release);
51        crate::events::registry::unregister_session(&self.session_id);
52        // Cleanup only — fail-soft when no tokio runtime is current.
53        if let Ok(handle) = tokio::runtime::Handle::try_current() {
54            let hook_bus = Arc::clone(&self.hook_bus);
55            let session_id = self.session_id.clone();
56            handle.spawn(async move {
57                hook_bus.clear_session_injection(&session_id).await;
58            });
59        }
60    }
61}
62
63impl Drop for BackgroundTasks {
64    fn drop(&mut self) {
65        self.watcher_shutdown
66            .store(true, std::sync::atomic::Ordering::Relaxed);
67        self.socket_shutdown
68            .store(true, std::sync::atomic::Ordering::Relaxed);
69        self.watcher_task.abort();
70        self.socket_task.abort();
71    }
72}
73
74/// Result of the boot sequence — everything a renderer needs to start.
75pub struct EngineBoot {
76    pub runtime: Runtime,
77    pub config: crate::SynapsConfig,
78    /// Echo of EngineOpts.no_extensions — callers gate extension discovery
79    /// on this so the flag has one source of truth.
80    pub no_extensions: bool,
81    pub session: Session,
82    pub api_messages: Vec<crate::SharedMessage>,
83    pub total_input_tokens: u64,
84    pub total_output_tokens: u64,
85    pub session_cost: f64,
86    pub abort_context: Option<String>,
87    pub continued: bool,
88    pub continue_info: Option<ContinueInfo>,
89    pub registry: Arc<CommandRegistry>,
90    /// Keybind registry. Uses std::sync::RwLock (not tokio) because keybind
91    /// lookups are synchronous, fast, and called from input handling code
92    /// that cannot await. This is safe as long as the lock is never held
93    /// across an await point.
94    pub keybind_registry: Arc<std::sync::RwLock<KeybindRegistry>>,
95    pub mcp_server_count: usize,
96    pub system_prompt_path: std::path::PathBuf,
97    pub ext_manager: Arc<RwLock<crate::extensions::manager::ExtensionManager>>,
98    /// Background tasks — inbox watcher, socket listener. Aborts on drop.
99    pub background: BackgroundTasks,
100}
101
102/// Info about how a continued session was resolved.
103pub struct ContinueInfo {
104    pub session_id: String,
105    pub resolved_via: Option<String>, // "chain", "name", "compacted", or None
106    pub query: String,
107    /// F24: set when the requested session was compacted into a successor.
108    pub compaction_notice: Option<String>,
109}
110
111/// Run the full engine boot sequence:
112/// config → system prompt → skills → MCP → session → sockets → extensions
113pub async fn boot(opts: EngineOpts) -> Result<EngineBoot> {
114    // Process-global parts (profile, logging, HTTP client, registry, skills,
115    // MCP, extension manager) are built ONCE per process by `EngineHost`
116    // and reused by every later boot in the same process. The log-appender
117    // guard lives on the host — process lifetime ≥ renderer lifetime — so
118    // log lines emitted after boot() returns are never silently dropped.
119    let host = EngineHost::boot_and_install(HostOpts {
120        profile: opts.profile.clone(),
121        no_extensions: opts.no_extensions,
122    })
123    .await?;
124    // `apply_config` is applied inside `foreground_runtime()` — before
125    // session resolution, same relative order as before.
126    let mut runtime = host.foreground_runtime().await?;
127    let config: crate::SynapsConfig = (**host.config()).clone();
128
129    let sb = resolve_session_and_prompt(
130        &mut runtime,
131        &opts.continue_session,
132        opts.system.as_deref(),
133        opts.prompt_manifest.as_deref(),
134    )?;
135
136    // Skills, command registry, MCP setup and the MCP lease manager are
137    // host-owned (see `EngineHost::boot`); the runtime already holds them.
138    let registry = Arc::clone(host.command_registry());
139    let keybind_registry = Arc::clone(host.keybind_registry());
140    let mcp_server_count = host.mcp_server_count();
141
142    let system_prompt_path = crate::config::resolve_read_path("system.md");
143
144    // Session was resolved before policy compilation so its model is the immutable
145    // foreground identity used by worker inheritance and authorization.
146
147    let background = spawn_session_background(&runtime, &sb.session)?;
148
149    finish_session_setup(&mut runtime, &config, &sb.session, None, IndexRecord::Start);
150
151    // Extension manager: host-owned.
152    let ext_manager = Arc::clone(host.ext_manager());
153
154    if mcp_server_count > 0 {
155        tracing::info!(
156            "{} MCP servers available (use connect_mcp_server to activate)",
157            mcp_server_count
158        );
159    }
160
161    Ok(EngineBoot {
162        runtime,
163        config,
164        no_extensions: opts.no_extensions,
165        session: sb.session,
166        api_messages: sb.api_messages,
167        total_input_tokens: sb.total_input_tokens,
168        total_output_tokens: sb.total_output_tokens,
169        session_cost: sb.session_cost,
170        abort_context: sb.abort_context,
171        continued: sb.continued,
172        continue_info: sb.continue_info,
173        registry,
174        keybind_registry,
175        mcp_server_count,
176        system_prompt_path,
177        ext_manager,
178        background,
179    })
180}
181
182/// Session resolution + prompt/orchestration install (code motion from
183/// `boot()`; called per session by `SessionActor::create` too).
184pub(crate) fn resolve_session_and_prompt(
185    runtime: &mut Runtime,
186    continue_session: &Option<Option<String>>,
187    system: Option<&str>,
188    prompt_manifest: Option<&std::path::Path>,
189) -> Result<SessionBootResult> {
190    // Resolve the final foreground route before compiling immutable delegation
191    // policy. Continuing a session may replace the configured model.
192    let sb = resolve_or_create_session(runtime, continue_session)?;
193    runtime.set_session_id(Some(sb.session.id.clone()));
194    // merge(112): seed continuation state from the resolved session so a
195    // --continue resume picks up the context-continuation high-water mark.
196    runtime.reset_context_continuation(&sb.session.id, &sb.api_messages);
197
198    // Validate and compile an opted-in manifest before any session/network work.
199    let legacy_prompt = crate::config::resolve_system_prompt(system);
200    if let Some(path) = prompt_manifest {
201        let raw = std::fs::read_to_string(path)
202            .map_err(|_| crate::RuntimeError::Config("prompt manifest is unavailable".into()))?;
203        let manifest = agent_core::prompt::PromptManifest::parse(&raw)
204            .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
205        let registry = manifest
206            .registry(path.parent())
207            .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
208        let model = crate::orchestration::canonical_foreground_identity(runtime.model())
209            .map_err(|e| crate::RuntimeError::Config(format!("invalid foreground model: {e}")))?;
210        let context = agent_core::prompt::SelectionContext::new(model.clone(), None)
211            .map_err(|e| crate::RuntimeError::Config(e.to_string()))?;
212        let catalog = crate::orchestration::OrchestrationRuntime::trusted_catalog(
213            &model,
214            manifest.delegation_catalog_candidates(),
215        )
216        .map_err(|error| crate::RuntimeError::Config(error.into()))?;
217        let delegation_policy = manifest
218            .delegation_policy(model.clone(), &catalog)
219            .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
220        let delegation_policy_digest = delegation_policy.as_ref().map(|policy| policy.digest());
221        if let Some(policy) = delegation_policy {
222            runtime.install_orchestration(Arc::new(
223                crate::orchestration::OrchestrationRuntime::new(policy),
224            ));
225        } else {
226            runtime.install_orchestration(Arc::new(
227                crate::orchestration::OrchestrationRuntime::baseline(model.clone(), 8, 64)
228                    .map_err(|error| crate::RuntimeError::Config(error.into()))?,
229            ));
230        }
231        let user = system
232            .map(|_| {
233                agent_core::prompt::resolved_system_prompt_as_user_module(legacy_prompt.clone())
234            })
235            .transpose()
236            .map_err(|e| crate::RuntimeError::Config(e.to_string()))?;
237        let stack =
238            agent_core::prompt::compile_prompt_stack(&manifest, &registry, &context, user.clone())
239                .map_err(|e| {
240                    crate::RuntimeError::Config(format!("invalid prompt manifest: {e}"))
241                })?;
242        runtime
243            .apply_prompt_stack(stack)
244            .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
245        runtime.retain_prompt_reload_source(
246            path.to_path_buf(),
247            context,
248            user,
249            delegation_policy_digest,
250        );
251    } else {
252        let foreground = crate::orchestration::canonical_foreground_identity(runtime.model())
253            .map_err(|e| crate::RuntimeError::Config(format!("invalid foreground model: {e}")))?;
254        runtime.install_orchestration(Arc::new(
255            crate::orchestration::OrchestrationRuntime::baseline(foreground, 8, 64)
256                .map_err(|error| crate::RuntimeError::Config(error.into()))?,
257        ));
258        runtime.set_system_prompt(legacy_prompt);
259    }
260
261    Ok(sb)
262}
263
264/// Inbox watcher + per-session UDS listener + registry registration (code
265/// motion from `boot()`). Fails loudly when registration fails.
266pub(crate) fn spawn_session_background(
267    runtime: &Runtime,
268    session: &Session,
269) -> Result<BackgroundTasks> {
270    // Start inbox watcher
271    let watcher_shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
272    let watcher_task = {
273        let inbox_dir = crate::config::base_dir().join("inbox");
274        let event_queue = runtime.event_queue().clone();
275        let shutdown = watcher_shutdown.clone();
276        tokio::spawn(async move {
277            crate::events::watch_inbox(inbox_dir, event_queue, shutdown).await;
278        })
279    };
280
281    // Helper: abort background tasks on error
282    let abort_tasks = |ws: &Arc<std::sync::atomic::AtomicBool>,
283                       wt: &tokio::task::JoinHandle<()>| {
284        ws.store(true, std::sync::atomic::Ordering::Relaxed);
285        wt.abort();
286    };
287
288    // Start per-session Unix socket listener + register in session registry
289    let socket_shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
290    let session_socket_path = crate::events::registry::socket_path_for_session(&session.id);
291    let socket_task = crate::events::socket::listen_session_socket(
292        session_socket_path.clone(),
293        runtime.event_queue().clone(),
294        socket_shutdown.clone(),
295    );
296    let session_registration = crate::events::registry::SessionRegistration {
297        kind: crate::events::registry::REGISTRATION_KIND.to_string(),
298        session_id: session.id.clone(),
299        name: session.name.clone(),
300        socket_path: session_socket_path.clone(),
301        pid: std::process::id(),
302        started_at: chrono::Utc::now(),
303    };
304    if let Err(e) = crate::events::registry::register_session(&session_registration) {
305        abort_tasks(&watcher_shutdown, &watcher_task);
306        socket_shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
307        socket_task.abort();
308        // Fail loudly: returning Ok with already-aborted handles silently
309        // poisoned downstream — server inherited dead watcher/socket tasks
310        // and a session that wasn't in the registry, so other tools couldn't
311        // see it. Better to fail boot than start in a broken state.
312        return Err(crate::core::error::RuntimeError::Session(format!(
313            "failed to register session {}: {}",
314            session_registration.session_id, e
315        )));
316    }
317
318    Ok(BackgroundTasks {
319        watcher_shutdown,
320        watcher_task,
321        socket_shutdown,
322        socket_task,
323        session_socket_path,
324        session_id: session.id.clone(),
325        hook_bus: Arc::clone(runtime.hook_bus()),
326        // The appender guard lives on the `EngineHost` now.
327        log_guard: None,
328    })
329}
330
331/// Whether `finish_session_setup` appends the session START index record.
332/// `Skip` on unpark (B3) / reload rehydrate (C3): the session already has one.
333#[derive(Clone, Copy, Debug, PartialEq, Eq)]
334pub(crate) enum IndexRecord {
335    Start,
336    Skip,
337}
338
339/// Foreground turn budget + session start index record (code motion from
340/// `boot()`). `cwd` = the session's configured cwd (`None` → process cwd).
341pub(crate) fn finish_session_setup(
342    runtime: &mut Runtime,
343    config: &crate::SynapsConfig,
344    session: &Session,
345    cwd: Option<std::path::PathBuf>,
346    index_record: IndexRecord,
347) {
348    // Task 23: the engine's interactive session runs under the FOREGROUND
349    // turn budget with typed per-role config overrides applied.
350    runtime.set_turn_budget(crate::runtime::budget::TurnBudget::from_config(
351        crate::runtime::budget::TurnRole::Foreground,
352        &config.turn_budgets,
353    ));
354
355    // Session start index record.
356    //
357    // The `on_session_start` HOOK is deliberately NOT emitted here. Extensions
358    // are loaded by the host after boot returns (see
359    // `extensions::loader::spawn_discover_and_load`), so emitting at this
360    // point delivered the event to an empty bus in every host — the hook had
361    // never once reached an extension. It is now emitted by the loader, after
362    // subscribers exist.
363    if index_record == IndexRecord::Start {
364        let mut index_record =
365            crate::core::session_index::SessionIndexRecord::start(&session.id);
366        index_record.model = Some(session.model.clone());
367        index_record.profile = crate::core::config::get_profile();
368        index_record.cwd = cwd.or_else(|| std::env::current_dir().ok());
369        if let Err(err) = crate::core::session_index::append_record(&index_record) {
370            tracing::warn!("failed to append session start index record: {}", err);
371        }
372    }
373
374}
375
376/// Result of session resolution.
377pub(crate) struct SessionBootResult {
378    pub(crate) session: Session,
379    pub(crate) api_messages: Vec<crate::SharedMessage>,
380    pub(crate) total_input_tokens: u64,
381    pub(crate) total_output_tokens: u64,
382    pub(crate) session_cost: f64,
383    pub(crate) abort_context: Option<String>,
384    pub(crate) continued: bool,
385    pub(crate) continue_info: Option<ContinueInfo>,
386}
387
388fn resolve_or_create_session(
389    runtime: &mut Runtime,
390    continue_session: &Option<Option<String>>,
391) -> Result<SessionBootResult> {
392    match continue_session {
393        Some(ref maybe_id) => {
394            let raw_session = match maybe_id {
395                Some(ref id) => resolve_session(id).map_err(|e| {
396                    crate::error::RuntimeError::Tool(format!(
397                        "Failed to load session '{}': {}",
398                        id, e
399                    ))
400                })?,
401                None => latest_session().map_err(|e| {
402                    crate::error::RuntimeError::Tool(format!("No sessions to continue: {}", e))
403                })?,
404            };
405            // F24: follow compacted_into forward so --continue <old> lands
406            // on the final successor, never on a pre-compaction fork point.
407            let resolved = agent_core::session::follow_compaction_chain(raw_session)
408                .map_err(|e| crate::error::RuntimeError::Session(e.to_string()))?;
409            let compaction_notice = resolved.compaction_notice;
410            let mut session = resolved.session;
411            runtime.set_model(session.model.clone());
412            // Restore the session's named reasoning level so max/ultra/off
413            // and custom budgets survive --continue — then clamp against the
414            // model so old grok+xhigh session files don't resume into a
415            // permanently failing state.
416            if let Some(clamp) = runtime.restore_session_reasoning(&session.thinking_level) {
417                tracing::warn!(
418                    from = %clamp.from,
419                    to = %clamp.to,
420                    model = %session.model,
421                    "saved session thinking level not supported by model; clamped"
422                );
423                session.thinking_level = runtime.thinking_level().to_string();
424            }
425            if let Some(ref sp) = session.system_prompt {
426                runtime.set_system_prompt(sp.clone());
427            }
428
429            let continue_info = {
430                let (resolved_via, query) = match maybe_id {
431                    Some(ref q) => {
432                        let via = if *q != session.id {
433                            if crate::chain::load_chain(q).is_ok() {
434                                Some("chain".to_string())
435                            } else if agent_core::session::find_session_by_name(q).is_ok() {
436                                Some("name".to_string())
437                            } else if compaction_notice.is_some() {
438                                Some("compacted".to_string())
439                            } else {
440                                None
441                            }
442                        } else {
443                            None
444                        };
445                        (via, q.clone())
446                    }
447                    None => {
448                        let via = compaction_notice.as_ref().map(|_| "compacted".to_string());
449                        (via, session.id.clone())
450                    }
451                };
452                // Always produce ContinueInfo when we have a compaction notice,
453                // even for bare --continue (no explicit id).
454                if maybe_id.is_some() || compaction_notice.is_some() {
455                    Some(ContinueInfo {
456                        session_id: session.id.clone(),
457                        resolved_via,
458                        query,
459                        compaction_notice: compaction_notice.clone(),
460                    })
461                } else {
462                    None
463                }
464            };
465
466            Ok(SessionBootResult {
467                api_messages: session.api_messages.clone(),
468                total_input_tokens: session.total_input_tokens,
469                total_output_tokens: session.total_output_tokens,
470                session_cost: session.session_cost,
471                abort_context: session.abort_context.clone(),
472                continued: true,
473                continue_info,
474                session,
475            })
476        }
477        None => {
478            let session = Session::new(
479                runtime.model(),
480                runtime.thinking_level(),
481                runtime.system_prompt(),
482            );
483            Ok(SessionBootResult {
484                session,
485                api_messages: Vec::new(),
486                total_input_tokens: 0,
487                total_output_tokens: 0,
488                session_cost: 0.0,
489                abort_context: None,
490                continued: false,
491                continue_info: None,
492            })
493        }
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use agent_core::reasoning::ReasoningLevel;
501
502    /// B1: --continue path must restore thinking_level from the saved session.
503    /// Simulates what resolve_or_create_session does when a session is continued.
504    #[test]
505    fn continue_path_restores_thinking_level_from_session() {
506        let mut runtime = Runtime::new_headless();
507
508        // Simulate what resolve_or_create_session does on --continue.
509        let thinking_level_str = "ultra";
510        if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
511            runtime.set_reasoning_level_explicit(level);
512        }
513
514        assert_eq!(
515            runtime.reasoning_level(),
516            ReasoningLevel::Ultra,
517            "thinking level must be restored from session on --continue"
518        );
519        assert!(
520            runtime.is_reasoning_explicit(),
521            "restored thinking level must be marked explicit so set_model won't overwrite it"
522        );
523    }
524
525    #[test]
526    fn continue_path_restores_max_level() {
527        let mut runtime = Runtime::new_headless();
528        let thinking_level_str = "max";
529        if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
530            runtime.set_reasoning_level_explicit(level);
531        }
532        assert_eq!(runtime.reasoning_level(), ReasoningLevel::Max);
533    }
534
535    #[test]
536    fn continue_path_restores_off_level() {
537        let mut runtime = Runtime::new_headless();
538        let thinking_level_str = "off";
539        if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
540            runtime.set_reasoning_level_explicit(level);
541        }
542        assert_eq!(runtime.reasoning_level(), ReasoningLevel::Off);
543    }
544
545    /// merge(112) DARK test: default config boots with legacy memory and no
546    /// context_checkpoint tool — Axel features are invisible until opted in.
547    #[test]
548    fn default_config_is_dark_no_checkpoint_tool_legacy_binding() {
549        let runtime = Runtime::new_headless();
550        // Default memory backend must be legacy (no Axel sidecar spawn).
551        assert!(
552            !runtime.memory_backend_exclusive(),
553            "default config must NOT be exclusive (Axel); it must be legacy"
554        );
555        // The tool registry must not contain context_checkpoint.
556        let tools = runtime.tools_shared();
557        let registry = tools.blocking_read();
558        let has_checkpoint = registry
559            .iter_tools_sorted()
560            .iter()
561            .any(|t| t.name() == "context_checkpoint");
562        assert!(
563            !has_checkpoint,
564            "context_checkpoint must not appear in the default tool set"
565        );
566    }
567}