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    /// Whether the inbox watcher may take shared-inbox files now. A daemon
28    /// session holds it open only while a client owns its input; living here,
29    /// it survives park/unpark with the watcher it gates.
30    inbox_gate: Arc<std::sync::atomic::AtomicBool>,
31    endpoint: crate::events::registry::SessionEndpoint,
32    session_id: String,
33    /// Hook bus the session's `on_session_start` injection lives on; cleared
34    /// at shutdown so a long-lived process does not accumulate stale keys.
35    hook_bus: Arc<crate::extensions::hooks::HookBus>,
36    /// File-appender flush guard. Holding this for the lifetime of the
37    /// renderer keeps the non-blocking log writer's background thread
38    /// alive — without it, log lines emitted after `boot()` returns can
39    /// be silently dropped before they reach disk. Dropped last when
40    /// BackgroundTasks drops.
41    #[allow(dead_code)]
42    log_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
43}
44
45impl BackgroundTasks {
46    pub(crate) fn update_session_name(
47        &self,
48        name: Option<&str>,
49    ) -> std::result::Result<(), String> {
50        self.endpoint.update_name(name)
51    }
52
53    /// Open or close the inbox watcher's gate (see [`spawn_session_background_gated`]).
54    pub(crate) fn set_inbox_accepting(&self, accepting: bool) {
55        self.inbox_gate
56            .store(accepting, std::sync::atomic::Ordering::Release);
57    }
58
59    /// Signal all tasks to stop and unregister the session.
60    pub fn shutdown(&self) {
61        self.watcher_shutdown
62            .store(true, std::sync::atomic::Ordering::Release);
63        self.endpoint.shutdown();
64        // Cleanup only — fail-soft when no tokio runtime is current.
65        if let Ok(handle) = tokio::runtime::Handle::try_current() {
66            let hook_bus = Arc::clone(&self.hook_bus);
67            let session_id = self.session_id.clone();
68            handle.spawn(async move {
69                hook_bus.clear_session_injection(&session_id).await;
70            });
71        }
72    }
73}
74
75impl Drop for BackgroundTasks {
76    fn drop(&mut self) {
77        self.watcher_shutdown
78            .store(true, std::sync::atomic::Ordering::Relaxed);
79        self.watcher_task.abort();
80        self.endpoint.shutdown();
81    }
82}
83
84/// Result of the boot sequence — everything a renderer needs to start.
85pub struct EngineBoot {
86    pub runtime: Runtime,
87    pub config: crate::SynapsConfig,
88    /// Echo of EngineOpts.no_extensions — callers gate extension discovery
89    /// on this so the flag has one source of truth.
90    pub no_extensions: bool,
91    pub session: Session,
92    pub api_messages: Vec<crate::SharedMessage>,
93    pub total_input_tokens: u64,
94    pub total_output_tokens: u64,
95    pub session_cost: f64,
96    pub continued: bool,
97    pub continue_info: Option<ContinueInfo>,
98    pub registry: Arc<CommandRegistry>,
99    /// Keybind registry. Uses std::sync::RwLock (not tokio) because keybind
100    /// lookups are synchronous, fast, and called from input handling code
101    /// that cannot await. This is safe as long as the lock is never held
102    /// across an await point.
103    pub keybind_registry: Arc<std::sync::RwLock<KeybindRegistry>>,
104    pub mcp_server_count: usize,
105    pub system_prompt_path: std::path::PathBuf,
106    pub ext_manager: Arc<RwLock<crate::extensions::manager::ExtensionManager>>,
107    /// Background tasks — inbox watcher, socket listener. Aborts on drop.
108    pub background: BackgroundTasks,
109}
110
111/// Info about how a continued session was resolved.
112pub struct ContinueInfo {
113    pub session_id: String,
114    pub resolved_via: Option<String>, // "chain", "name", "compacted", or None
115    pub query: String,
116    /// F24: set when the requested session was compacted into a successor.
117    pub compaction_notice: Option<String>,
118}
119
120/// Run the full engine boot sequence:
121/// config → system prompt → skills → MCP → session → sockets → extensions
122pub async fn boot(opts: EngineOpts) -> Result<EngineBoot> {
123    // Process-global parts (profile, logging, HTTP client, registry, skills,
124    // MCP, extension manager) are built ONCE per process by `EngineHost`
125    // and reused by every later boot in the same process. The log-appender
126    // guard lives on the host — process lifetime ≥ renderer lifetime — so
127    // log lines emitted after boot() returns are never silently dropped.
128    let host = EngineHost::boot_and_install(HostOpts {
129        profile: opts.profile.clone(),
130        no_extensions: opts.no_extensions,
131        mcp_discovery: crate::McpDiscovery::Inline,
132    })
133    .await?;
134    // `apply_config` is applied inside `foreground_runtime()` — before
135    // session resolution, same relative order as before.
136    let mut runtime = host.foreground_runtime().await?;
137    let config: crate::SynapsConfig = (**host.config()).clone();
138
139    let sb = resolve_session_and_prompt(
140        &mut runtime,
141        &opts.continue_session,
142        opts.system.as_deref(),
143        opts.prompt_manifest.as_deref(),
144    )?;
145
146    // Skills, command registry, MCP setup and the MCP lease manager are
147    // host-owned (see `EngineHost::boot`); the runtime already holds them.
148    let registry = Arc::clone(host.command_registry());
149    let keybind_registry = Arc::clone(host.keybind_registry());
150    let mcp_server_count = host.mcp_server_count();
151
152    let system_prompt_path = crate::config::resolve_read_path("system.md");
153
154    // Session was resolved before policy compilation so its model is the immutable
155    // foreground identity used by worker inheritance and authorization.
156
157    let background = spawn_session_background(&runtime, &sb.session)?;
158
159    finish_session_setup(&mut runtime, &config, &sb.session, None, IndexRecord::Start);
160
161    // Extension manager: host-owned.
162    let ext_manager = Arc::clone(host.ext_manager());
163
164    if mcp_server_count > 0 {
165        tracing::info!(
166            "{} MCP tools available from configured servers",
167            mcp_server_count
168        );
169    }
170
171    Ok(EngineBoot {
172        runtime,
173        config,
174        no_extensions: opts.no_extensions,
175        session: sb.session,
176        api_messages: sb.api_messages,
177        total_input_tokens: sb.total_input_tokens,
178        total_output_tokens: sb.total_output_tokens,
179        session_cost: sb.session_cost,
180        continued: sb.continued,
181        continue_info: sb.continue_info,
182        registry,
183        keybind_registry,
184        mcp_server_count,
185        system_prompt_path,
186        ext_manager,
187        background,
188    })
189}
190
191/// Session resolution + prompt/orchestration install (code motion from
192/// `boot()`; called per session by `SessionActor::create` too).
193pub(crate) fn resolve_session_and_prompt(
194    runtime: &mut Runtime,
195    continue_session: &Option<Option<String>>,
196    system: Option<&str>,
197    prompt_manifest: Option<&std::path::Path>,
198) -> Result<SessionBootResult> {
199    // Resolve the final foreground route before compiling immutable delegation
200    // policy. Continuing a session may replace the configured model.
201    let sb = resolve_or_create_session(runtime, continue_session)?;
202    runtime.set_session_id(Some(sb.session.id.clone()));
203    // merge(112): seed continuation state from the resolved session so a
204    // --continue resume picks up the context-continuation high-water mark.
205    runtime.reset_context_continuation(&sb.session.id, &sb.api_messages);
206
207    // Validate and compile an opted-in manifest before any session/network work.
208    let legacy_prompt = crate::config::resolve_system_prompt(system);
209    if let Some(path) = prompt_manifest {
210        let raw = std::fs::read_to_string(path)
211            .map_err(|_| crate::RuntimeError::Config("prompt manifest is unavailable".into()))?;
212        let manifest = agent_core::prompt::PromptManifest::parse(&raw)
213            .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
214        let registry = manifest
215            .registry(path.parent())
216            .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
217        let model = crate::orchestration::canonical_foreground_identity(runtime.model())
218            .map_err(|e| crate::RuntimeError::Config(format!("invalid foreground model: {e}")))?;
219        let context = agent_core::prompt::SelectionContext::new(model.clone(), None)
220            .map_err(|e| crate::RuntimeError::Config(e.to_string()))?;
221        let catalog = crate::orchestration::OrchestrationRuntime::trusted_catalog(
222            &model,
223            manifest.delegation_catalog_candidates(),
224        )
225        .map_err(|error| crate::RuntimeError::Config(error.into()))?;
226        let delegation_policy = manifest
227            .delegation_policy(model.clone(), &catalog)
228            .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
229        let delegation_policy_digest = delegation_policy.as_ref().map(|policy| policy.digest());
230        if let Some(policy) = delegation_policy {
231            // The manifest's policy owns concurrency and totals; nesting
232            // depth is not part of a manifest, so the user's limit applies.
233            runtime.install_orchestration(Arc::new(
234                crate::orchestration::OrchestrationRuntime::new(policy)
235                    .with_max_depth(runtime.subagent_limits().max_depth)
236                    .map_err(|error| crate::RuntimeError::Config(error.into()))?,
237            ));
238        } else {
239            runtime.install_orchestration(Arc::new(
240                crate::orchestration::OrchestrationRuntime::from_limits(
241                    model.clone(),
242                    runtime.subagent_limits(),
243                )
244                .map_err(|error| crate::RuntimeError::Config(error.into()))?,
245            ));
246        }
247        let user = system
248            .map(|_| {
249                agent_core::prompt::resolved_system_prompt_as_user_module(legacy_prompt.clone())
250            })
251            .transpose()
252            .map_err(|e| crate::RuntimeError::Config(e.to_string()))?;
253        let stack =
254            agent_core::prompt::compile_prompt_stack(&manifest, &registry, &context, user.clone())
255                .map_err(|e| {
256                    crate::RuntimeError::Config(format!("invalid prompt manifest: {e}"))
257                })?;
258        runtime
259            .apply_prompt_stack(stack)
260            .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
261        runtime.retain_prompt_reload_source(
262            path.to_path_buf(),
263            context,
264            user,
265            delegation_policy_digest,
266        );
267    } else {
268        let foreground = crate::orchestration::canonical_foreground_identity(runtime.model())
269            .map_err(|e| crate::RuntimeError::Config(format!("invalid foreground model: {e}")))?;
270        runtime.install_orchestration(Arc::new(
271            crate::orchestration::OrchestrationRuntime::from_limits(
272                foreground,
273                runtime.subagent_limits(),
274            )
275            .map_err(|error| crate::RuntimeError::Config(error.into()))?,
276        ));
277        runtime.set_system_prompt(legacy_prompt);
278    }
279
280    Ok(sb)
281}
282
283/// Inbox watcher + per-session UDS listener + registry registration (code
284/// motion from `boot()`). Fails loudly when registration fails. The inbox
285/// gate starts open: an in-process session always has its user at hand.
286pub(crate) fn spawn_session_background(
287    runtime: &Runtime,
288    session: &Session,
289) -> Result<BackgroundTasks> {
290    spawn_session_background_gated(
291        runtime,
292        session,
293        Arc::new(std::sync::atomic::AtomicBool::new(true)),
294    )
295}
296
297/// [`spawn_session_background`] whose inbox watcher takes shared-inbox files
298/// only while `inbox_gate` is true. The per-session socket is not gated:
299/// events addressed to this session always arrive.
300pub(crate) fn spawn_session_background_gated(
301    runtime: &Runtime,
302    session: &Session,
303    inbox_gate: Arc<std::sync::atomic::AtomicBool>,
304) -> Result<BackgroundTasks> {
305    // Start inbox watcher
306    let watcher_shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
307    let watcher_task = {
308        let inbox_dir = crate::config::base_dir().join("inbox");
309        let event_queue = runtime.event_queue().clone();
310        let shutdown = watcher_shutdown.clone();
311        let gate = Arc::clone(&inbox_gate);
312        tokio::spawn(async move {
313            crate::events::watch_inbox_gated(
314                inbox_dir,
315                event_queue,
316                shutdown,
317                crate::events::ingest::accept_all,
318                gate,
319            )
320            .await;
321        })
322    };
323
324    // Helper: abort background tasks on error
325    let abort_tasks = |ws: &Arc<std::sync::atomic::AtomicBool>,
326                       wt: &tokio::task::JoinHandle<()>| {
327        ws.store(true, std::sync::atomic::Ordering::Relaxed);
328        wt.abort();
329    };
330
331    let endpoint = match crate::events::registry::SessionEndpoint::start(
332        &session.id,
333        session.name.clone(),
334        runtime.event_queue().clone(),
335    ) {
336        Ok(endpoint) => endpoint,
337        Err(e) => {
338            abort_tasks(&watcher_shutdown, &watcher_task);
339            return Err(crate::core::error::RuntimeError::Session(e));
340        }
341    };
342
343    Ok(BackgroundTasks {
344        watcher_shutdown,
345        watcher_task,
346        inbox_gate,
347        endpoint,
348        session_id: session.id.clone(),
349        hook_bus: Arc::clone(runtime.hook_bus()),
350        // The appender guard lives on the `EngineHost` now.
351        log_guard: None,
352    })
353}
354
355/// Whether `finish_session_setup` appends the session START index record.
356/// `Skip` on unpark (B3) / reload rehydrate (C3): the session already has one.
357#[derive(Clone, Copy, Debug, PartialEq, Eq)]
358pub(crate) enum IndexRecord {
359    Start,
360    Skip,
361}
362
363/// Foreground turn budget + session start index record (code motion from
364/// `boot()`). `cwd` = the session's configured cwd (`None` → process cwd).
365pub(crate) fn finish_session_setup(
366    runtime: &mut Runtime,
367    config: &crate::SynapsConfig,
368    session: &Session,
369    cwd: Option<std::path::PathBuf>,
370    index_record: IndexRecord,
371) {
372    // Task 23: the engine's interactive session runs under the FOREGROUND
373    // turn budget with typed per-role config overrides applied.
374    runtime.set_turn_budget(crate::runtime::budget::TurnBudget::from_config(
375        crate::runtime::budget::TurnRole::Foreground,
376        &config.turn_budgets,
377    ));
378
379    // Session start index record.
380    //
381    // The `on_session_start` HOOK is deliberately NOT emitted here. Extensions
382    // are loaded by the host after boot returns (see
383    // `extensions::loader::spawn_discover_and_load`), so emitting at this
384    // point delivered the event to an empty bus in every host — the hook had
385    // never once reached an extension. It is now emitted by the loader, after
386    // subscribers exist.
387    if index_record == IndexRecord::Start {
388        let mut index_record =
389            crate::core::session_index::SessionIndexRecord::start(&session.id);
390        index_record.model = Some(session.model.clone());
391        index_record.profile = crate::core::config::get_profile();
392        index_record.cwd = cwd.or_else(|| std::env::current_dir().ok());
393        if let Err(err) = crate::core::session_index::append_record(&index_record) {
394            tracing::warn!("failed to append session start index record: {}", err);
395        }
396    }
397
398}
399
400/// Result of session resolution.
401pub(crate) struct SessionBootResult {
402    pub(crate) session: Session,
403    pub(crate) api_messages: Vec<crate::SharedMessage>,
404    pub(crate) total_input_tokens: u64,
405    pub(crate) total_output_tokens: u64,
406    pub(crate) session_cost: f64,
407    pub(crate) continued: bool,
408    pub(crate) continue_info: Option<ContinueInfo>,
409}
410
411/// The session lock for a `boot()` host that drives `Runtime` itself (rpc,
412/// `synaps server`). Continuing a session that another process (a TUI, the
413/// daemon, another rpc) holds is REFUSED: two writers would fork its history
414/// and the older one's saves would overwrite the newer's. So is continuing
415/// a session already compacted into a successor (its history is closed). A
416/// fresh session, or a lock that cannot be taken for another reason (e.g. a
417/// read-only sessions dir), proceeds best-effort, as the session actor does.
418/// The lock is held for the returned value's lifetime; a holder that
419/// continued a session runs crash recovery (`recover_turn_draft`).
420pub fn lock_session(
421    id: &str,
422    continued: bool,
423    kind: &str,
424) -> Result<Option<agent_core::session_lock::SessionLock>> {
425    let dir = agent_core::session_lock::sessions_dir();
426    let holder = agent_core::session_lock::LockHolder {
427        pid: std::process::id(),
428        kind: kind.to_string(),
429    };
430    match agent_core::session_lock::SessionLock::try_acquire(&dir, id, holder) {
431        Ok(lock) => Ok(Some(lock)),
432        Err(
433            e @ (agent_core::session_lock::SessionLockError::Held { .. }
434            | agent_core::session_lock::SessionLockError::CompactedInto { .. }),
435        ) if continued => Err(crate::RuntimeError::Session(e.to_string())),
436        Err(e) => {
437            tracing::warn!(session = %id, "session lock: {e}");
438            Ok(None)
439        }
440    }
441}
442
443/// Crash recovery: if `sessions/<id>.turn` exists, the process that last ran
444/// this session died with a turn open. Fold the draft into the history
445/// (`engine::interrupt::recover_crashed_turn`), persist that, THEN remove
446/// the draft.
447///
448/// Run ONLY by the holder of the session lock, after taking it (actor
449/// create, unpark and `/resume`; rpc and `synaps server` after
450/// `lock_session`): a draft under a lock held elsewhere belongs to a turn
451/// that is running right now. Legacy chat takes no lock and never recovers.
452/// Loading itself (`resolve_or_create_session`) leaves the draft alone.
453///
454/// The draft is removed only once the recovered history is on disk; if that
455/// save fails the draft stays, and the next holder retries (recovery is
456/// idempotent through its own marker).
457pub async fn recover_turn_draft(conv: &mut crate::engine::session::ConversationState) {
458    let dir = agent_core::session_lock::sessions_dir();
459    let id = conv.session.id.clone();
460    let read = {
461        let (dir, id) = (dir.clone(), id.clone());
462        tokio::task::spawn_blocking(move || {
463            agent_core::core::session_draft::read_turn_draft(&dir, &id)
464        })
465        .await
466        .map_err(std::io::Error::other)
467        .and_then(|r| r)
468    };
469    let recovered = match read {
470        Ok(None) => return,
471        Ok(Some(draft)) => {
472            crate::engine::interrupt::recover_crashed_turn(&mut conv.api_messages, &draft)
473        }
474        Err(e) => {
475            // Unreadable draft: never block the load. The history stays
476            // exactly as saved; the draft is removed below.
477            tracing::warn!(session = %id, "unreadable turn draft, removing it: {e}");
478            false
479        }
480    };
481    if recovered {
482        tracing::warn!(
483            session = %id,
484            "session was interrupted mid-turn by an unexpected stop; recovered"
485        );
486        if !conv.save().await {
487            tracing::warn!(
488                session = %id,
489                "could not save the recovered history; keeping the turn draft"
490            );
491            return;
492        }
493    } else {
494        // The turn had concluded (or the draft was unreadable): the saved
495        // history is already right, only the draft's removal was lost.
496        tracing::info!(session = %id, "removing a stale turn draft");
497    }
498    let removed = tokio::task::spawn_blocking(move || {
499        agent_core::core::session_draft::remove_turn_draft(&dir, &id)
500    })
501    .await;
502    if let Ok(Err(e)) | Err(e) = removed.map_err(std::io::Error::other) {
503        tracing::warn!(session = %conv.session.id, "failed to remove turn draft: {e}");
504    }
505}
506
507fn resolve_or_create_session(
508    runtime: &mut Runtime,
509    continue_session: &Option<Option<String>>,
510) -> Result<SessionBootResult> {
511    match continue_session {
512        Some(ref maybe_id) => {
513            let raw_session = match maybe_id {
514                Some(ref id) => resolve_session(id).map_err(|e| {
515                    crate::error::RuntimeError::Tool(format!(
516                        "Failed to load session '{}': {}",
517                        id, e
518                    ))
519                })?,
520                None => latest_session().map_err(|e| {
521                    crate::error::RuntimeError::Tool(format!("No sessions to continue: {}", e))
522                })?,
523            };
524            // F24: follow compacted_into forward so --continue <old> lands
525            // on the final successor, never on a pre-compaction fork point.
526            let resolved = agent_core::session::follow_compaction_chain(raw_session)
527                .map_err(|e| crate::error::RuntimeError::Session(e.to_string()))?;
528            let compaction_notice = resolved.compaction_notice;
529            let mut session = resolved.session;
530            runtime.set_model(session.model.clone());
531            // Restore the session's named reasoning level so max/ultra/off
532            // and custom budgets survive --continue — then clamp against the
533            // model so old grok+xhigh session files don't resume into a
534            // permanently failing state.
535            if let Some(clamp) = runtime.restore_session_reasoning(&session.thinking_level) {
536                tracing::warn!(
537                    from = %clamp.from,
538                    to = %clamp.to,
539                    model = %session.model,
540                    "saved session thinking level not supported by model; clamped"
541                );
542                session.thinking_level = runtime.thinking_level().to_string();
543            }
544            if let Some(ref sp) = session.system_prompt {
545                runtime.set_system_prompt(sp.clone());
546            }
547
548            let continue_info = {
549                let (resolved_via, query) = match maybe_id {
550                    Some(ref q) => {
551                        let via = if *q != session.id {
552                            if crate::chain::load_chain(q).is_ok() {
553                                Some("chain".to_string())
554                            } else if agent_core::session::find_session_by_name(q).is_ok() {
555                                Some("name".to_string())
556                            } else if compaction_notice.is_some() {
557                                Some("compacted".to_string())
558                            } else {
559                                None
560                            }
561                        } else {
562                            None
563                        };
564                        (via, q.clone())
565                    }
566                    None => {
567                        let via = compaction_notice.as_ref().map(|_| "compacted".to_string());
568                        (via, session.id.clone())
569                    }
570                };
571                // Always produce ContinueInfo when we have a compaction notice,
572                // even for bare --continue (no explicit id).
573                if maybe_id.is_some() || compaction_notice.is_some() {
574                    Some(ContinueInfo {
575                        session_id: session.id.clone(),
576                        resolved_via,
577                        query,
578                        compaction_notice: compaction_notice.clone(),
579                    })
580                } else {
581                    None
582                }
583            };
584
585            // Sessions saved before the interruption marker existed carry a
586            // recap in `abort_context`, meant to be prepended to the next user
587            // message. Migrate here, for actor create, unpark and `boot()`
588            // (`/resume` migrates in `ConversationState::from_resumed`), and
589            // before the continuation seed, so everything downstream sees the
590            // migrated history. The recap is dropped; the marker is appended
591            // (append-only: the cached prefix is untouched).
592            if crate::engine::interrupt::migrate_legacy_abort_context(
593                &mut session.api_messages,
594                &mut session.abort_context,
595            ) {
596                tracing::info!(
597                    session = %session.id,
598                    "migrated a legacy abort-context recap to an interruption marker"
599                );
600            }
601            // A turn draft (`sessions/<id>.turn`) is NOT folded in here:
602            // loading runs before (or without) the session lock. The lock
603            // holder does it (`recover_turn_draft`).
604
605            Ok(SessionBootResult {
606                api_messages: session.api_messages.clone(),
607                total_input_tokens: session.total_input_tokens,
608                total_output_tokens: session.total_output_tokens,
609                session_cost: session.session_cost,
610                continued: true,
611                continue_info,
612                session,
613            })
614        }
615        None => {
616            let session = Session::new(
617                runtime.model(),
618                runtime.thinking_level(),
619                runtime.system_prompt(),
620            );
621            Ok(SessionBootResult {
622                session,
623                api_messages: Vec::new(),
624                total_input_tokens: 0,
625                total_output_tokens: 0,
626                session_cost: 0.0,
627                continued: false,
628                continue_info: None,
629            })
630        }
631    }
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637    use agent_core::reasoning::ReasoningLevel;
638
639    /// A session's registry card and socket must not outlive it, even when
640    /// it ends without `shutdown()` (S348: 470 leftovers in `run/`), and
641    /// `shutdown()` followed by drop stays clean.
642    #[cfg(unix)]
643    #[tokio::test]
644    #[serial_test::serial(synaps_base_dir)]
645    async fn session_registration_is_removed_on_drop_without_shutdown() {
646        let _base = crate::test_env::BaseDirGuard::new();
647        let runtime = Runtime::new_headless();
648        let dir = crate::events::registry::registry_dir();
649        for explicit_shutdown in [false, true] {
650            let session = Session::new("claude-sonnet-4-5", "low", None);
651            let card = dir.join(format!("{}.json", session.id));
652            let background = spawn_session_background(&runtime, &session).unwrap();
653            let sock = std::path::PathBuf::from(&background.endpoint.registration.socket_path);
654            for _ in 0..100 {
655                if sock.exists() {
656                    break;
657                }
658                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
659            }
660            assert!(card.exists() && sock.exists(), "registered and listening");
661            if explicit_shutdown {
662                background.shutdown();
663            }
664            drop(background);
665            assert!(
666                !card.exists(),
667                "card removed (shutdown: {explicit_shutdown})"
668            );
669            assert!(
670                !sock.exists(),
671                "socket removed (shutdown: {explicit_shutdown})"
672            );
673        }
674    }
675
676    /// Starting a session drops registrations whose owner process is gone.
677    #[cfg(unix)]
678    #[tokio::test]
679    #[serial_test::serial(synaps_base_dir)]
680    async fn starting_a_session_prunes_registrations_of_dead_processes() {
681        let _base = crate::test_env::BaseDirGuard::new();
682        let dir = crate::events::registry::registry_dir();
683        let dead = crate::events::registry::SessionRegistration {
684            kind: crate::events::registry::REGISTRATION_KIND.to_string(),
685            session_id: "20200101-000000-dead".into(),
686            name: None,
687            socket_path: crate::events::registry::socket_path_for_session("20200101-000000-dead"),
688            pid: 999_999,
689            started_at: chrono::Utc::now(),
690        };
691        crate::events::registry::register_session(&dead).unwrap();
692        let _stale = std::os::unix::net::UnixListener::bind(&dead.socket_path).unwrap();
693
694        let runtime = Runtime::new_headless();
695        let session = Session::new("claude-sonnet-4-5", "low", None);
696        let background = spawn_session_background(&runtime, &session).unwrap();
697        assert!(!dir.join("20200101-000000-dead.json").exists());
698        assert!(!std::path::Path::new(&dead.socket_path).exists());
699        background.shutdown();
700    }
701
702    async fn saved_session(first: &str) -> Session {
703        let mut session = Session::new("claude-sonnet-4-5", "low", None);
704        session.api_messages = vec![std::sync::Arc::new(
705            serde_json::json!({"role": "user", "content": first}),
706        )];
707        session.save().await.unwrap();
708        session
709    }
710
711    /// Loading runs before (or without) the session lock, so it must never
712    /// fold a turn draft in: `rpc --continue` on a session whose turn is
713    /// running in another process would otherwise record a crash that never
714    /// happened. (Review finding: `boot()` callers never lock.)
715    #[tokio::test]
716    #[serial_test::serial(synaps_base_dir)]
717    async fn loading_never_folds_or_touches_the_draft() {
718        use agent_core::core::session_draft::{read_turn_draft, write_turn_draft, TurnDraft};
719        let _base = crate::test_env::BaseDirGuard::new();
720        let dir = agent_core::session_lock::sessions_dir();
721        let session = saved_session("do X").await;
722        let draft = TurnDraft {
723            base_len: 1,
724            partial_text: "partial".into(),
725        };
726        write_turn_draft(&dir, &session.id, &draft).unwrap();
727
728        let mut runtime = Runtime::new_headless();
729        let sb = resolve_or_create_session(&mut runtime, &Some(Some(session.id.clone()))).unwrap();
730        assert_eq!(sb.api_messages.len(), 1, "history exactly as saved");
731        assert_eq!(
732            read_turn_draft(&dir, &session.id).unwrap(),
733            Some(draft),
734            "the draft is left for the lock holder"
735        );
736    }
737
738    /// The lock holder folds the draft in, saves, THEN removes the draft.
739    #[tokio::test]
740    #[serial_test::serial(synaps_base_dir)]
741    async fn the_lock_holder_recovers_saves_then_removes_the_draft() {
742        use agent_core::core::session_draft::{read_turn_draft, write_turn_draft, TurnDraft};
743        let _base = crate::test_env::BaseDirGuard::new();
744        let dir = agent_core::session_lock::sessions_dir();
745        let session = saved_session("do X").await;
746        let id = session.id.clone();
747        write_turn_draft(
748            &dir,
749            &id,
750            &TurnDraft {
751                base_len: 1,
752                partial_text: "partial".into(),
753            },
754        )
755        .unwrap();
756
757        let mut conv = crate::engine::session::ConversationState::from_resumed(session);
758        recover_turn_draft(&mut conv).await;
759        assert_eq!(conv.api_messages.len(), 3, "partial reply + marker");
760        let on_disk = Session::load(&id).unwrap();
761        assert_eq!(on_disk.api_messages, conv.api_messages, "saved before removal");
762        assert_eq!(read_turn_draft(&dir, &id).unwrap(), None, "draft removed");
763
764        // No draft: nothing to do.
765        recover_turn_draft(&mut conv).await;
766        assert_eq!(conv.api_messages.len(), 3);
767    }
768
769    /// An unreadable draft never blocks the load: history untouched, draft
770    /// removed.
771    #[tokio::test]
772    #[serial_test::serial(synaps_base_dir)]
773    async fn an_unreadable_draft_is_removed_and_history_kept() {
774        let _base = crate::test_env::BaseDirGuard::new();
775        let dir = agent_core::session_lock::sessions_dir();
776        let session = saved_session("do X").await;
777        let path = dir.join(format!("{}.turn", session.id));
778        std::fs::write(&path, b"{not json").unwrap();
779        let mut conv = crate::engine::session::ConversationState::from_resumed(session);
780        recover_turn_draft(&mut conv).await;
781        assert_eq!(conv.api_messages.len(), 1);
782        assert!(!path.exists());
783    }
784
785    /// If the recovered history cannot be saved, the draft stays: removing
786    /// it would lose the only record that the turn was cut off.
787    #[cfg(unix)]
788    #[tokio::test]
789    #[serial_test::serial(synaps_base_dir)]
790    async fn a_failed_recovery_save_keeps_the_draft() {
791        use agent_core::core::session_draft::{read_turn_draft, write_turn_draft, TurnDraft};
792        let _base = crate::test_env::BaseDirGuard::new();
793        let dir = agent_core::session_lock::sessions_dir();
794        let session = saved_session("do X").await;
795        let id = session.id.clone();
796        let draft = TurnDraft {
797            base_len: 1,
798            partial_text: "partial".into(),
799        };
800        write_turn_draft(&dir, &id, &draft).unwrap();
801        // Make the snapshot unwritable: a non-empty directory where the
802        // `<id>.json` file goes (the atomic rename onto it fails).
803        let snapshot = dir.join(format!("{id}.json"));
804        std::fs::remove_file(&snapshot).unwrap();
805        std::fs::create_dir(&snapshot).unwrap();
806        std::fs::write(snapshot.join("occupied"), b"x").unwrap();
807
808        let mut conv = crate::engine::session::ConversationState::from_resumed(session);
809        recover_turn_draft(&mut conv).await;
810        assert_eq!(read_turn_draft(&dir, &id).unwrap(), Some(draft), "draft kept");
811    }
812
813    /// A `boot()` host may not continue a session another holder has; a
814    /// fresh one is locked (and the lock is released on drop).
815    #[tokio::test]
816    #[serial_test::serial(synaps_base_dir)]
817    async fn boot_hosts_refuse_to_continue_a_session_held_elsewhere() {
818        let _base = crate::test_env::BaseDirGuard::new();
819        let held = lock_session("20260930-000000-held", true, "tui").unwrap();
820        assert!(held.is_some());
821        let err = lock_session("20260930-000000-held", true, "rpc").unwrap_err();
822        assert!(err.to_string().contains("20260930-000000-held"), "{err}");
823        // Not continuing (a fresh id) never refuses.
824        assert!(lock_session("20260930-000000-held", false, "rpc").unwrap().is_none());
825        drop(held);
826        assert!(lock_session("20260930-000000-held", true, "rpc").unwrap().is_some());
827
828        // A session compacted into a successor is closed: never continued.
829        let mut old = Session::new("claude-sonnet-4-5", "low", None);
830        old.api_messages = vec![std::sync::Arc::new(
831            serde_json::json!({"role": "user", "content": "before compaction"}),
832        )];
833        old.compacted_into = Some("20260930-000001-next".into());
834        old.save().await.unwrap();
835        let err = lock_session(&old.id, true, "rpc").unwrap_err();
836        assert!(err.to_string().contains("20260930-000001-next"), "{err}");
837    }
838
839    /// B1: --continue path must restore thinking_level from the saved session.
840    /// Simulates what resolve_or_create_session does when a session is continued.
841    #[test]
842    fn continue_path_restores_thinking_level_from_session() {
843        let mut runtime = Runtime::new_headless();
844
845        // Simulate what resolve_or_create_session does on --continue.
846        let thinking_level_str = "ultra";
847        if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
848            runtime.set_reasoning_level_explicit(level);
849        }
850
851        assert_eq!(
852            runtime.reasoning_level(),
853            ReasoningLevel::Ultra,
854            "thinking level must be restored from session on --continue"
855        );
856        assert!(
857            runtime.is_reasoning_explicit(),
858            "restored thinking level must be marked explicit so set_model won't overwrite it"
859        );
860    }
861
862    #[test]
863    fn continue_path_restores_max_level() {
864        let mut runtime = Runtime::new_headless();
865        let thinking_level_str = "max";
866        if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
867            runtime.set_reasoning_level_explicit(level);
868        }
869        assert_eq!(runtime.reasoning_level(), ReasoningLevel::Max);
870    }
871
872    #[test]
873    fn continue_path_restores_off_level() {
874        let mut runtime = Runtime::new_headless();
875        let thinking_level_str = "off";
876        if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
877            runtime.set_reasoning_level_explicit(level);
878        }
879        assert_eq!(runtime.reasoning_level(), ReasoningLevel::Off);
880    }
881
882    /// merge(112) DARK test: default config boots with legacy memory and no
883    /// context_checkpoint tool — Axel features are invisible until opted in.
884    #[test]
885    fn default_config_is_dark_no_checkpoint_tool_legacy_binding() {
886        let runtime = Runtime::new_headless();
887        // Default memory backend must be legacy (no Axel sidecar spawn).
888        assert!(
889            !runtime.memory_backend_exclusive(),
890            "default config must NOT be exclusive (Axel); it must be legacy"
891        );
892        // The tool registry must not contain context_checkpoint.
893        let tools = runtime.tools_shared();
894        let registry = tools.blocking_read();
895        let has_checkpoint = registry
896            .iter_tools_sorted()
897            .iter()
898            .any(|t| t.name() == "context_checkpoint");
899        assert!(
900            !has_checkpoint,
901            "context_checkpoint must not appear in the default tool set"
902        );
903    }
904}