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