Skip to main content

agent_engine/
host.rs

1//! Process-global engine host: the things exactly one of which should exist
2//! per process regardless of how many sessions the process serves.
3//!
4//! `engine::setup::boot` used to construct all of this inside the session's
5//! `Runtime`. It now builds (or reuses) the [`EngineHost`] and borrows the
6//! process-global parts from it; subagent spawns borrow the same parts
7//! instead of rebuilding an HTTP client + registry + token cache per spawn.
8
9use std::sync::{Arc, OnceLock};
10use tokio::sync::RwLock;
11
12use crate::extensions::hooks::HookBus;
13use crate::runtime::RuntimeParts;
14use crate::session::{SessionActor, SessionConfig, SessionHandle, SessionId, SessionMeta};
15use crate::tools::catalog::CatalogGeneration;
16use crate::{Result, Runtime, ToolRegistry};
17
18/// Everything a `Runtime` borrows from its host rather than constructing.
19/// Cheap to clone (all `Arc`/handle types).
20#[derive(Clone)]
21pub struct HostParts {
22    pub client: reqwest::Client,
23    /// THE shared catalog.
24    pub tools: Arc<RwLock<ToolRegistry>>,
25    pub hook_bus: Arc<HookBus>,
26    /// Resolved once from config.
27    pub credential_source: crate::auth::CredentialSource,
28    /// ONE cache per process.
29    pub token_cache: crate::auth::TokenCache,
30    pub mcp_runtime: Option<Arc<crate::mcp::McpRuntimeManager>>,
31    pub extension_runtime: Option<Arc<crate::extensions::lease::ExtensionRuntimeManager>>,
32    pub capture_dir: std::path::PathBuf,
33    pub progressive_tool_disclosure: bool,
34}
35
36pub struct HostOpts {
37    /// Applied via `config::set_profile` by the FIRST boot in a process
38    /// only; a later `boot()` with a different profile is not honoured
39    /// (the installed host wins — see [`EngineHost::boot_and_install`]).
40    pub profile: Option<String>,
41    /// Echoed to callers (`EngineBoot::no_extensions`) — extension discovery
42    /// is gated by the renderer after boot, not by the host. Not read here.
43    pub no_extensions: bool,
44    /// When MCP servers without cached descriptors are discovered.
45    /// [`McpDiscovery::Inline`] (in-process modes) discovers them during
46    /// `boot()`; [`McpDiscovery::Background`] (the daemon) boots with cached
47    /// descriptors only and leaves discovery to
48    /// [`EngineHost::spawn_background_mcp_discovery`], so a hung MCP server
49    /// can never hold up readiness.
50    pub mcp_discovery: McpDiscovery,
51}
52
53pub use crate::mcp::McpDiscovery;
54
55pub struct EngineHost {
56    parts: HostParts,
57    config: arc_swap::ArcSwap<crate::SynapsConfig>,
58    command_registry: Arc<crate::skills::registry::CommandRegistry>,
59    keybind_registry: Arc<std::sync::RwLock<crate::skills::keybinds::KeybindRegistry>>,
60    ext_manager: Arc<RwLock<crate::extensions::manager::ExtensionManager>>,
61    mcp_server_count: usize,
62    /// Worker (subagent) registry template, cached by shared-catalog
63    /// generation. `worker_runtime()` clones this instead of rebuilding
64    /// `without_subagent_with_extensions` + `rebuild_schema()` per spawn.
65    worker_registry: std::sync::Mutex<Option<(CatalogGeneration, ToolRegistry)>>,
66    /// File-appender flush guard. Lives for the process (≥ any renderer), so
67    /// log lines emitted after `boot()` returns can never be dropped by an
68    /// early guard drop (the old `setup::boot` bug). Rust runs no static
69    /// destructors, so it is released explicitly by [`Self::flush_logs`] at
70    /// process exit — otherwise the tail of the log dies with the writer
71    /// thread.
72    log_guard: std::sync::Mutex<Option<tracing_appender::non_blocking::WorkerGuard>>,
73    /// Live sessions (Phase 2): id → handle. The map holds one handle per
74    /// actor so the actor outlives any single client; the actor task removes
75    /// itself here when it finishes.
76    sessions: std::sync::Mutex<std::collections::HashMap<SessionId, SessionHandle>>,
77    /// Serialises `create_session`'s resolve-then-insert so two concurrent
78    /// `--continue X` cannot both miss the live check and build two actors
79    /// on one session id.
80    create_lock: tokio::sync::Mutex<()>,
81    /// (E-P2) Single-tenancy driver lock: at most one plugin can drive one
82    /// session daemon-wide. Grants do NOT survive reload (§3 S2/S5).
83    driver_grants: std::sync::Mutex<std::collections::HashMap<String, SessionId>>,
84    /// C2: flips to `true` when extension discovery on `ext_manager` has
85    /// finished (the loader sets it; `extensions_ready()` awaits it). The
86    /// paired flag records that a loader was DISPATCHED, so a session
87    /// created between `spawn_discover_and_load` and its first lock
88    /// acquisition still waits instead of racing the walk.
89    extensions_ready: tokio::sync::watch::Sender<bool>,
90    extensions_loading: std::sync::atomic::AtomicBool,
91    /// MCP discovery deferred by [`McpDiscovery::Background`]; taken (once)
92    /// by [`Self::spawn_background_mcp_discovery`].
93    pending_mcp_discovery: std::sync::Mutex<Option<crate::mcp::PendingMcpDiscovery>>,
94    /// One `on_session_start` at a time on this host, a hook that a turn
95    /// waits for first. A reload restores every session at once; their
96    /// hooks, all fired together, would queue in each single-process
97    /// extension and the tail would pass the handler timeout and lose its
98    /// injection for the whole session.
99    session_start_gate: crate::session::session_start::SessionStartGate,
100    /// The daemon's pin book (set once by `Daemon::start`): sessions on this
101    /// host write their pin changes through it. `None` in-process.
102    pin_sink: std::sync::RwLock<Option<Arc<dyn crate::session::PinSink>>>,
103}
104
105/// (E-P2) Returned by [`EngineHost::claim_driver`] when another live session
106/// already holds the driver grant for a plugin.
107#[derive(Debug)]
108#[allow(dead_code)] // fields read by Display impl + P3 handler
109pub struct DriverGrantError {
110    pub plugin: String,
111    pub held_by: SessionId,
112}
113
114impl std::fmt::Display for DriverGrantError {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        write!(
117            f,
118            "plugin '{}' already driving session {}",
119            self.plugin, self.held_by
120        )
121    }
122}
123
124static HOST: OnceLock<Arc<EngineHost>> = OnceLock::new();
125
126/// See [`EngineHost::extensions_loading_guard`].
127pub struct ExtensionsLoadingGuard {
128    host: Arc<EngineHost>,
129}
130
131impl Drop for ExtensionsLoadingGuard {
132    fn drop(&mut self) {
133        self.host.mark_extensions_ready();
134    }
135}
136
137impl EngineHost {
138    /// Every step of the old `setup::boot()` that does NOT mention a session:
139    /// profile, logging, HTTP client, registry, config, skills, MCP, extension
140    /// manager. Touches NO process static: the OpenAI routing static is
141    /// written by [`Self::install`] for the winning host only, and the global
142    /// broker is installed in [`Self::foreground_runtime`] via `apply_config`,
143    /// exactly where the old boot did it relative to session resolution.
144    pub async fn boot(opts: HostOpts) -> Result<Arc<Self>> {
145        if let Some(ref prof) = opts.profile {
146            crate::config::set_profile(Some(prof.clone()));
147        }
148        let log_guard = crate::logging::init_logging();
149
150        let client = crate::runtime::build_host_http_client()?;
151        let capture_dir = crate::runtime::trace::default_capture_dir();
152        let _ = crate::runtime::trace::sweep_expired_captures(&capture_dir);
153
154        let config = crate::config::load_config();
155        let tools = Arc::new(RwLock::new(ToolRegistry::new()));
156        // Same point the old boot's `apply_config` disabled tools: on the
157        // fresh registry, before skills/MCP register anything.
158        if !config.disabled_tools.is_empty() {
159            tools.write().await.disable(&config.disabled_tools);
160        }
161
162        // Discover plugins/skills, build command registry, register load_skill tool.
163        let (command_registry, keybind_registry) = crate::skills::register(&tools, &config).await;
164
165        // MCP (if configured): descriptor-backed tools, executed through
166        // per-session leases in every mode. With progressive disclosure they
167        // stay dormant until activated; without it they are part of every
168        // session's core. A configured server with no cached descriptors is
169        // discovered once (parallel, bounded, negative-cached): here for
170        // `Inline`, later in a background task for `Background` (daemon).
171        let (mcp_server_count, pending_mcp_discovery) = match opts.mcp_discovery {
172            McpDiscovery::Inline => (crate::mcp::setup_lazy_mcp(&tools).await, None),
173            McpDiscovery::Background => crate::mcp::setup_cached_mcp(&tools).await,
174        };
175        let mcp_runtime = Some(Arc::new(crate::mcp::McpRuntimeManager::new(
176            crate::mcp::lease::config_source_from_disk(),
177            crate::mcp::lease::DEFAULT_IDLE_MAX,
178        )));
179
180        let hook_bus = Arc::new(HookBus::new());
181        let mut ext_mgr = crate::extensions::manager::ExtensionManager::new_with_tools(
182            Arc::clone(&hook_bus),
183            Arc::clone(&tools),
184        );
185        // Per-process: tell the extension manager whether memory is exclusive
186        // (Axel) so extensions spawned later inherit the right policy.
187        let mem_binding = crate::memory_backend::MemoryBinding::from_config(&config.memory_backend);
188        ext_mgr.bind_memory_backend(mem_binding.exclusive());
189        ext_mgr.set_progressive_deferral(config.progressive_tool_disclosure);
190        let extension_runtime = if config.progressive_tool_disclosure {
191            Some(ext_mgr.extension_runtime())
192        } else {
193            None
194        };
195        let ext_manager = Arc::new(RwLock::new(ext_mgr));
196
197        let parts = HostParts {
198            client,
199            tools,
200            hook_bus,
201            credential_source: config.auth.credential_source(),
202            token_cache: crate::auth::TokenCache::new(),
203            mcp_runtime,
204            extension_runtime,
205            capture_dir,
206            progressive_tool_disclosure: config.progressive_tool_disclosure,
207        };
208
209        Ok(Arc::new(Self {
210            parts,
211            config: arc_swap::ArcSwap::from_pointee(config),
212            command_registry,
213            keybind_registry,
214            ext_manager,
215            mcp_server_count,
216            worker_registry: std::sync::Mutex::new(None),
217            log_guard: std::sync::Mutex::new(log_guard),
218            sessions: std::sync::Mutex::new(std::collections::HashMap::new()),
219            create_lock: tokio::sync::Mutex::new(()),
220            driver_grants: std::sync::Mutex::new(std::collections::HashMap::new()),
221            extensions_ready: tokio::sync::watch::channel(false).0,
222            extensions_loading: std::sync::atomic::AtomicBool::new(false),
223            pending_mcp_discovery: std::sync::Mutex::new(pending_mcp_discovery),
224            session_start_gate: crate::session::session_start::SessionStartGate::new(),
225            pin_sink: std::sync::RwLock::new(None),
226        }))
227    }
228
229    /// [`McpDiscovery::Background`]: start the deferred MCP discovery in a
230    /// background task (at most once per host; `None` when nothing is
231    /// pending — `Inline` boot, every server cached, or already spawned).
232    /// The daemon calls this after it is accepting. On completion ONLY the
233    /// newly discovered servers' dormant tools are added to the shared
234    /// registry, as one atomic `try_register_batch` (a rejected batch is
235    /// logged and fails closed). Like late extension registration this
236    /// mutates the shared catalog while sessions may be live: with
237    /// progressive disclosure on the tools are dormant (searchable,
238    /// activatable); with it off they are core tools that every live session
239    /// sends from its NEXT request — one prompt-cache rewrite per live
240    /// session, once. The task resolves to the number of tools registered.
241    pub fn spawn_background_mcp_discovery(&self) -> Option<tokio::task::JoinHandle<usize>> {
242        let pending = self
243            .pending_mcp_discovery
244            .lock()
245            .unwrap_or_else(|e| e.into_inner())
246            .take()?;
247        let tools = Arc::clone(&self.parts.tools);
248        tracing::info!(
249            servers = ?pending.server_names(),
250            "MCP: discovering uncached servers in the background"
251        );
252        Some(tokio::spawn(async move { pending.run(&tools).await }))
253    }
254
255    /// Idempotent, atomic install of the process host. Exactly one host ever
256    /// wins the `OnceLock`; the winner's extension manager becomes the
257    /// OpenAI routing static in the same step. A different host arriving
258    /// later (or losing a race) is returned as `Err(rejected)` and never
259    /// replaces the installed one — nor touches any static.
260    pub fn install(host: Arc<Self>) -> std::result::Result<(), Arc<Self>> {
261        let installed = HOST.get_or_init(|| {
262            crate::runtime::openai::set_extension_manager_for_routing(Arc::clone(
263                &host.ext_manager,
264            ));
265            Arc::clone(&host)
266        });
267        if Arc::ptr_eq(installed, &host) {
268            Ok(())
269        } else {
270            Err(host)
271        }
272    }
273
274    /// The process host: the installed one, else boot + install. Under a
275    /// race the loser is discarded and the installed host returned, so a
276    /// caller never ends up on a host that `current()` does not agree with.
277    pub async fn boot_and_install(opts: HostOpts) -> Result<Arc<Self>> {
278        if let Some(h) = HOST.get() {
279            return Ok(Arc::clone(h));
280        }
281        let h = Self::boot(opts).await?;
282        Ok(match Self::install(h) {
283            Ok(()) => HOST.get().cloned().expect("just installed"),
284            Err(_rejected) => HOST.get().cloned().expect("a winner exists"),
285        })
286    }
287
288    pub fn current() -> Option<Arc<Self>> {
289        HOST.get().cloned()
290    }
291
292    /// Flush the non-blocking log writer: drops the appender guard, which
293    /// drains the channel and joins the writer thread (≤ 1 s). Idempotent.
294    /// Lines logged AFTER this call are dropped, so it belongs at the very
295    /// last exit point (`main` return, panic hook), not at session shutdown.
296    pub fn flush_logs(&self) {
297        let guard = self.log_guard.lock().unwrap_or_else(|e| e.into_inner()).take();
298        drop(guard);
299    }
300
301    /// [`Self::flush_logs`] on the installed host, if any.
302    pub fn flush_installed_logs() {
303        if let Some(h) = HOST.get() {
304            h.flush_logs();
305        }
306    }
307
308    pub fn parts(&self) -> &HostParts {
309        &self.parts
310    }
311
312    pub fn config(&self) -> arc_swap::Guard<Arc<crate::SynapsConfig>> {
313        self.config.load()
314    }
315
316    /// Headless hosts (`synaps chat`, `rpc`, daemon) have no startup notice
317    /// area: print config warnings (unknown keys, bad or deprecated values)
318    /// to stderr and the log. The TUI shows them as notices instead.
319    pub fn report_config_warnings(&self) {
320        report_config_warnings(&self.config());
321    }
322
323    /// Install the daemon's pin book (each daemon start on this host
324    /// replaces it: the host is process-global, a daemon is not).
325    pub fn set_pin_sink(&self, sink: Arc<dyn crate::session::PinSink>) {
326        *self.pin_sink.write().unwrap_or_else(|e| e.into_inner()) = Some(sink);
327    }
328
329    pub(crate) fn pin_sink(&self) -> Option<Arc<dyn crate::session::PinSink>> {
330        self.pin_sink.read().unwrap_or_else(|e| e.into_inner()).clone()
331    }
332
333    /// The host's `on_session_start` gate (one hook at a time).
334    pub(crate) fn session_start_gate(&self) -> &crate::session::session_start::SessionStartGate {
335        &self.session_start_gate
336    }
337
338    pub fn ext_manager(&self) -> &Arc<RwLock<crate::extensions::manager::ExtensionManager>> {
339        &self.ext_manager
340    }
341
342    pub fn command_registry(&self) -> &Arc<crate::skills::registry::CommandRegistry> {
343        &self.command_registry
344    }
345
346    pub fn keybind_registry(
347        &self,
348    ) -> &Arc<std::sync::RwLock<crate::skills::keybinds::KeybindRegistry>> {
349        &self.keybind_registry
350    }
351
352    pub fn mcp_server_count(&self) -> usize {
353        self.mcp_server_count
354    }
355
356    /// The foreground runtime for an interactive session: shares client,
357    /// tools, hook_bus, credential source, token cache, MCP/extension lease
358    /// managers. Everything else is fresh (exactly `Runtime::new()`'s values).
359    /// `apply_config` runs here — the ONLY place the broker is (re)installed.
360    /// `disabled_tools` is NOT re-applied: `boot()` did it once on the fresh
361    /// registry, before skills/MCP registered, exactly where the old boot did.
362    pub async fn foreground_runtime(&self) -> Result<Runtime> {
363        self.foreground_runtime_for(None).await
364    }
365
366    /// `foreground_runtime()` for a session whose cwd is not the process cwd
367    /// (daemon-hosted sessions). The cwd is set BEFORE `apply_config` so the
368    /// memory binding (`apply_memory_backend_config`, one-shot) scopes to the
369    /// session's project, not the daemon's — A-engine-merge §4. `None` =
370    /// process cwd, byte-identical to `foreground_runtime()`.
371    pub async fn foreground_runtime_for(&self, cwd: Option<std::path::PathBuf>) -> Result<Runtime> {
372        self.foreground_runtime_with(cwd, true).await
373    }
374
375    /// `foreground_runtime_for`, applying the operator's `memory.default_mode`
376    /// only when `memory_default` — a session runtime rebuilt for a session
377    /// that made its own `/memory` choice passes `false` and restores that
378    /// choice instead (`SessionConfig::memory_mode`).
379    pub(crate) async fn foreground_runtime_with(
380        &self,
381        cwd: Option<std::path::PathBuf>,
382        memory_default: bool,
383    ) -> Result<Runtime> {
384        let mut runtime = Runtime::from_parts(RuntimeParts::with_reaper(self.parts.clone()));
385        runtime.set_cwd(cwd);
386        let config = self.config();
387        runtime.apply_config_keep_tools(&config);
388        // Every interactive session starts in the operator's confirmed
389        // `memory.default_mode` (after the backend binding above). A failed
390        // grant is logged and leaves memory off; it never fails the session.
391        if memory_default {
392            let _ = runtime.apply_memory_default_mode(&config.memory);
393        }
394        Ok(runtime)
395    }
396
397    /// A worker (subagent) runtime. Shares credential source and token cache
398    /// (so NO broker re-install and NO cache eviction) and takes a CLONE of
399    /// the cached worker registry template. Does NOT share: the HTTP client
400    /// (fresh — see below), hook_bus (fresh, empty — hooks do not fire for
401    /// subagent tool calls), session_manager (fresh + reaper),
402    /// mcp_runtime/extension_runtime (`None`, as `Runtime::new()`).
403    ///
404    /// Own client: workers `block_on` a throwaway `current_thread` runtime
405    /// and hyper parks each connection's I/O driver on the runtime that
406    /// opened it. A pooled connection born on a worker would be handed to
407    /// the foreground (LIFO checkout) and die mid-stream when that worker's
408    /// runtime dropped. A per-worker pool dies with its runtime, as before.
409    pub async fn worker_runtime(&self) -> Result<Runtime> {
410        let mut host = self.parts.clone();
411        host.client = crate::runtime::build_host_http_client()?;
412        host.hook_bus = Arc::new(HookBus::new());
413        host.tools = Arc::new(RwLock::new(self.worker_registry().await));
414        host.mcp_runtime = None;
415        host.extension_runtime = None;
416        // `Runtime::new()` value. Workers never ran `apply_config`, so they
417        // never had disclosure on; their registry has no discovery/activation
418        // tools, so a projected schema would silently drop every extension
419        // tool.
420        host.progressive_tool_disclosure = false;
421        let mut runtime = Runtime::from_parts(RuntimeParts::with_reaper(host));
422        runtime.apply_retry_config(&self.config());
423        Ok(runtime)
424    }
425
426    /// Cached-or-rebuilt worker registry: `without_subagent_with_extensions`
427    /// is a pure function of the shared registry's tool set, which is a pure
428    /// function of its catalog generation, so caching by generation returns
429    /// exactly what a rebuild would.
430    pub async fn worker_registry(&self) -> ToolRegistry {
431        let shared = self.parts.tools.read().await;
432        let gen = shared.catalog().generation();
433        if let Some((cached_gen, tpl)) = &*self.worker_registry.lock().unwrap() {
434            if *cached_gen == gen {
435                return tpl.clone();
436            }
437        }
438        let mut fresh = ToolRegistry::without_subagent_with_extensions(&shared);
439        drop(shared);
440        // #112: apply after the merge too — a shared registry must not
441        // reintroduce a forum tool the operator disabled. Other worker policy
442        // is preserved.
443        let disabled_forum: Vec<String> = self
444            .config()
445            .disabled_tools
446            .iter()
447            .filter(|name| matches!(name.as_str(), "forum_post" | "forum_read" | "forum_forget"))
448            .cloned()
449            .collect();
450        if !disabled_forum.is_empty() {
451            fresh.disable(&disabled_forum);
452        }
453        *self.worker_registry.lock().unwrap() = Some((gen, fresh.clone()));
454        fresh
455    }
456
457    /// Re-read config from disk into the host's snapshot. Phase 1 callers
458    /// keep `load_config()` where they use it today.
459    pub fn reload_config(&self) {
460        self.config
461            .store(Arc::new(crate::config::load_config()));
462    }
463
464    // ── sessions (Phase 2) ────────────────────────────────────────────────
465
466    /// Create a session on this host: builds the `SessionActor` (the one
467    /// `Runtime` + `ConversationState`), spawns its task, registers the
468    /// handle. The task removes itself from the map when it ends.
469    ///
470    /// **Attach-if-live:** when `cfg.continue_session` resolves to a session
471    /// that is already running on this host (`--continue X` twice,
472    /// `Attach::Create` twice, `--continue` latest while latest is live),
473    /// the existing handle is returned and NO second actor is built. Two
474    /// actors on one id would share the session file, and the second's
475    /// `spawn_session_background` would unlink the first's per-session UDS
476    /// and overwrite its registry entry. `cfg`'s other fields (cwd, model
477    /// override, …) are ignored in that case — the live session wins.
478    pub async fn create_session(self: &Arc<Self>, cfg: SessionConfig) -> Result<SessionHandle> {
479        let _serial = self.create_lock.lock().await;
480        if let Some(live) = self.live_continue_target(&cfg.continue_session) {
481            tracing::info!(session = %live.id, "create_session: target is live — attaching");
482            return Ok(live);
483        }
484        let (handle, task) = SessionActor::create(self, cfg).await?;
485        let id = handle.id.clone();
486        self.adopt_session(handle.clone());
487        let host = Arc::clone(self);
488        tokio::spawn(async move {
489            // A panicking actor must still leave the map (§6 #12): the
490            // handle would otherwise be listed forever and attach → Closed.
491            use futures::FutureExt;
492            if std::panic::AssertUnwindSafe(task.run())
493                .catch_unwind()
494                .await
495                .is_err()
496            {
497                tracing::error!(session = %id, "session actor panicked");
498            }
499            host.remove_session(&id);
500        });
501        Ok(handle)
502    }
503
504    /// Test seam: [`Self::create_session`] without spawning the actor, so a
505    /// test can seed session state (e.g. a fake worker in the subagent
506    /// registry) before `task.run()`. The handle is registered; the caller
507    /// spawns the task.
508    #[cfg(any(test, feature = "testing"))]
509    pub async fn create_session_unspawned(
510        self: &Arc<Self>,
511        cfg: SessionConfig,
512    ) -> Result<(SessionHandle, crate::session::SessionTask)> {
513        let (handle, task) = SessionActor::create(self, cfg).await?;
514        self.adopt_session(handle.clone());
515        Ok((handle, task))
516    }
517
518    /// Register a handle built elsewhere (daemon test factories). The
519    /// caller owns the task; `remove_session` when it ends.
520    pub fn adopt_session(&self, handle: SessionHandle) {
521        self.sessions
522            .lock()
523            .unwrap_or_else(|e| e.into_inner())
524            .insert(handle.id.clone(), handle);
525    }
526
527    /// Every registered handle (B4: THE session map; `DaemonState`
528    /// delegates here). Dead handles are dropped defensively: the actor
529    /// task drops `cmd_rx` when `run()` returns and `remove_session` runs
530    /// *after* — a listing in that window sees `is_alive() == false`. That
531    /// is an ordering window, not an invariant violation, so it is logged
532    /// at debug, never asserted.
533    pub fn session_handles(&self) -> Vec<SessionHandle> {
534        let mut map = self.sessions.lock().unwrap_or_else(|e| e.into_inner());
535        let before = map.len();
536        map.retain(|_, h| h.is_alive());
537        if before != map.len() {
538            tracing::debug!(
539                pruned = before - map.len(),
540                "session_handles: pruned ended sessions ahead of their self-removal"
541            );
542        }
543        map.values().cloned().collect()
544    }
545
546    /// Resolve a `continue_session` request against the LIVE sessions
547    /// first — actor id, current journal id (a LinkedSuccessor compaction
548    /// moves it off the actor id), or name (`--name` / `saveas`, which may
549    /// not be on disk yet) — then the way `setup::resolve_or_create_session`
550    /// will (chain → name → partial id; `Some(None)` = latest by mtime) and
551    /// match the resolved id against id *and* journal id. Anything that is
552    /// not running here returns `None` so the real boot path builds/reports.
553    ///
554    /// The daemon's `Attach::Create{continue}` goes through here too
555    /// (`host_factory` → `create_session`): a live journal is always one
556    /// actor, never a second one on the same file / UDS / registry entry.
557    fn live_continue_target(
558        &self,
559        continue_session: &Option<Option<String>>,
560    ) -> Option<SessionHandle> {
561        let query = continue_session.as_ref()?;
562        let handles: Vec<SessionHandle> = {
563            let sessions = self
564                .sessions
565                .lock()
566                .unwrap_or_else(|e| e.into_inner());
567            sessions.values().filter(|h| h.is_alive()).cloned().collect()
568        };
569        if handles.is_empty() {
570            return None;
571        }
572        let by_id = |id: &str| {
573            handles
574                .iter()
575                .find(|h| h.id.as_str() == id || h.journal_id() == id)
576                .cloned()
577        };
578        let id = match query {
579            Some(q) => {
580                if let Some(h) = by_id(q) {
581                    return Some(h);
582                }
583                if let Some(h) = handles.iter().find(|h| h.name().as_deref() == Some(q.as_str())) {
584                    return Some(h.clone());
585                }
586                // F24: resolve on disk and follow compacted_into so
587                // `--continue <old>` finds the live successor.
588                let session = crate::core::session::resolve_session(q).ok()?;
589                let resolved = crate::core::session::follow_compaction_chain(session).ok()?;
590                resolved.session.id
591            }
592            None => {
593                let session = crate::core::session::latest_session().ok()?;
594                let resolved = crate::core::session::follow_compaction_chain(session).ok()?;
595                resolved.session.id
596            }
597        };
598        by_id(&id)
599    }
600
601    /// Handle for a live session, if any.
602    pub fn attach(&self, id: &SessionId) -> Option<SessionHandle> {
603        self.sessions
604            .lock()
605            .unwrap_or_else(|e| e.into_inner())
606            .get(id)
607            .cloned()
608    }
609
610    /// Metadata of every live session, with the live cells filled
611    /// (lifecycle, clients, input_owner, awaiting_input, journal_id).
612    pub fn sessions(&self) -> Vec<SessionMeta> {
613        self.session_handles()
614            .iter()
615            .map(|h| h.meta_live())
616            .collect()
617    }
618
619    /// Drop the host's handle for a session (the actor keeps running until
620    /// its command queue closes or it receives `End`).
621    pub fn remove_session(&self, id: &SessionId) -> Option<SessionHandle> {
622        let handle = self.sessions
623            .lock()
624            .unwrap_or_else(|e| e.into_inner())
625            .remove(id);
626        // E-P2: release any driver grants held by this session.
627        if handle.is_some() {
628            self.release_all_driver_grants(id);
629        }
630        handle
631    }
632
633    // ── driver single-tenancy (E-P2, §3 S2/S5) ──────────────────────────
634
635    /// Claim the driver grant for `plugin` on behalf of `session`.
636    /// Returns `Ok(())` if the grant was acquired (or is idempotent for the
637    /// same session). Returns `Err` if another live session holds it.
638    #[allow(dead_code)] // consumed by P3 driver_arm
639    pub(crate) fn claim_driver(
640        &self,
641        plugin: &str,
642        session: &SessionId,
643    ) -> std::result::Result<(), DriverGrantError> {
644        let mut grants = self.driver_grants.lock().unwrap_or_else(|e| e.into_inner());
645        if let Some(existing) = grants.get(plugin) {
646            if existing != session {
647                return Err(DriverGrantError {
648                    plugin: plugin.into(),
649                    held_by: existing.clone(),
650                });
651            }
652            return Ok(()); // idempotent
653        }
654        grants.insert(plugin.into(), session.clone());
655        Ok(())
656    }
657
658    /// Release the driver grant for `plugin` if held by `session`.
659    #[allow(dead_code)] // consumed by P3 driver_revoke
660    pub(crate) fn release_driver(&self, plugin: &str, session: &SessionId) {
661        let mut grants = self.driver_grants.lock().unwrap_or_else(|e| e.into_inner());
662        if grants.get(plugin).is_some_and(|s| s == session) {
663            grants.remove(plugin);
664        }
665    }
666
667    /// Release all driver grants held by `session` (called from `remove_session`).
668    fn release_all_driver_grants(&self, session: &SessionId) {
669        let mut grants = self.driver_grants.lock().unwrap_or_else(|e| e.into_inner());
670        grants.retain(|_, s| s != session);
671    }
672
673    /// Clear all driver grants (called on reload — §3 S2/S5: plugin process
674    /// died with the old image).
675    #[allow(dead_code)] // consumed by reload path in P3+
676    pub(crate) fn clear_all_driver_grants(&self) {
677        self.driver_grants
678            .lock()
679            .unwrap_or_else(|e| e.into_inner())
680            .clear();
681    }
682
683    // ── shared extension host (Phase 2, C) ────────────────────────────────
684
685    /// Loader seam (C2): a discovery pass for this host's manager has been
686    /// dispatched. Called synchronously by `spawn_discover_and_load` before
687    /// its task runs, so `extensions_ready()` cannot slip through the gap.
688    pub fn note_extensions_loading(&self) {
689        self.extensions_loading
690            .store(true, std::sync::atomic::Ordering::Release);
691    }
692
693    /// Loader seam (C2): discovery finished — every `extensions_ready()`
694    /// waiter proceeds. Idempotent.
695    pub fn mark_extensions_ready(&self) {
696        self.extensions_ready.send_replace(true);
697    }
698
699    /// Loader seam (C2): `note_extensions_loading()` now, and
700    /// `mark_extensions_ready()` when the guard drops — however the loader
701    /// task ends (return, cancel, panic). Hold it inside the task so a
702    /// panicking walk cannot strand `extensions_ready()` waiters.
703    pub fn extensions_loading_guard(self: &Arc<Self>) -> ExtensionsLoadingGuard {
704        self.note_extensions_loading();
705        ExtensionsLoadingGuard {
706            host: Arc::clone(self),
707        }
708    }
709
710    /// Resolve once extension discovery on this host is known-finished, so a
711    /// session's `on_session_start` lands on subscribed extensions. Resolves
712    /// IMMEDIATELY when no loader was dispatched (hosts that never load
713    /// extensions, `--no-extensions`, tests) or discovery already completed
714    /// on the manager (in-process `discover_and_load()` callers such as
715    /// `synaps chat`); otherwise awaits `mark_extensions_ready()`.
716    ///
717    /// The watch sender is a field of the host, so "sender dropped" is NOT a
718    /// wake-up path while the host lives. Liveness comes from
719    /// [`ExtensionsLoadingGuard`] (the loader task marks ready on any exit,
720    /// including panic); callers that must not trust the loader at all
721    /// (`SessionActor::create`) additionally bound the await with
722    /// `budgets::EXTENSIONS_READY_TIMEOUT`.
723    pub async fn extensions_ready(&self) {
724        let mut rx = self.extensions_ready.subscribe();
725        if *rx.borrow() {
726            return;
727        }
728        // A running walk holds the manager write lock; this read waits it out.
729        if self.ext_manager.read().await.discovery_done().is_some() {
730            return;
731        }
732        if !self
733            .extensions_loading
734            .load(std::sync::atomic::Ordering::Acquire)
735        {
736            return;
737        }
738        while !*rx.borrow_and_update() {
739            if rx.changed().await.is_err() {
740                return;
741            }
742        }
743    }
744
745    /// C3: push one extension notification frame to every live session
746    /// (non-blocking; a session whose command queue is full drops it with a
747    /// warning — widget upserts are idempotent last-writer-wins). Frames
748    /// carry no session id today, hence broadcast. Returns how many
749    /// sessions accepted it.
750    pub async fn broadcast_extension_notification(
751        &self,
752        ext_id: &str,
753        method: &str,
754        params: serde_json::Value,
755    ) -> usize {
756        let handles: Vec<SessionHandle> = self
757            .sessions
758            .lock()
759            .unwrap_or_else(|e| e.into_inner())
760            .values()
761            .cloned()
762            .collect();
763        let mut delivered = 0;
764        for handle in handles {
765            let cmd = crate::session::SessionCommand::HostEvent(
766                crate::session::HostEvent::ExtensionNotification {
767                    extension_id: ext_id.to_string(),
768                    method: method.to_string(),
769                    params: params.clone(),
770                },
771            );
772            match handle.send(cmd).await {
773                Ok(()) => delivered += 1,
774                Err(err) => tracing::warn!(
775                    session = %handle.id,
776                    extension = %ext_id,
777                    method = %method,
778                    error = %err,
779                    "dropping extension notification for session"
780                ),
781            }
782        }
783        delivered
784    }
785}
786
787/// Print `config.warnings` to stderr and the log (headless hosts).
788pub fn report_config_warnings(config: &crate::SynapsConfig) {
789    for warning in &config.warnings {
790        tracing::warn!(%warning, "config warning");
791        eprintln!("synaps: config warning: {warning}");
792    }
793}
794
795#[cfg(test)]
796mod driver_grant_tests {
797    use super::*;
798
799    // Since EngineHost has many private fields we cannot construct in a unit
800    // test without the full boot path, test the grant logic on a standalone
801    // map that mirrors the real implementation exactly.
802
803    type Grants = std::sync::Mutex<std::collections::HashMap<String, SessionId>>;
804
805    fn claim(grants: &Grants, plugin: &str, session: &SessionId) -> std::result::Result<(), DriverGrantError> {
806        let mut g = grants.lock().unwrap();
807        if let Some(existing) = g.get(plugin) {
808            if existing != session {
809                return Err(DriverGrantError {
810                    plugin: plugin.into(),
811                    held_by: existing.clone(),
812                });
813            }
814            return Ok(());
815        }
816        g.insert(plugin.into(), session.clone());
817        Ok(())
818    }
819
820    fn release(grants: &Grants, plugin: &str, session: &SessionId) {
821        let mut g = grants.lock().unwrap();
822        if g.get(plugin).is_some_and(|s| s == session) {
823            g.remove(plugin);
824        }
825    }
826
827    fn release_all(grants: &Grants, session: &SessionId) {
828        grants.lock().unwrap().retain(|_, s| s != session);
829    }
830
831    fn clear_all(grants: &Grants) {
832        grants.lock().unwrap().clear();
833    }
834
835    fn sid(s: &str) -> SessionId {
836        SessionId::from(s)
837    }
838
839    #[test]
840    fn single_tenancy_second_session_refused() {
841        let grants: Grants = Default::default();
842        let a = sid("session-a");
843        let b = sid("session-b");
844        claim(&grants, "autonomous", &a).unwrap();
845        let err = claim(&grants, "autonomous", &b).unwrap_err();
846        assert_eq!(err.plugin, "autonomous");
847        assert_eq!(err.held_by, a);
848    }
849
850    #[test]
851    fn single_tenancy_released_on_end() {
852        let grants: Grants = Default::default();
853        let a = sid("session-a");
854        let b = sid("session-b");
855        claim(&grants, "autonomous", &a).unwrap();
856        release(&grants, "autonomous", &a);
857        // Now session B can claim it.
858        claim(&grants, "autonomous", &b).unwrap();
859    }
860
861    #[test]
862    fn single_tenancy_same_session_idempotent() {
863        let grants: Grants = Default::default();
864        let a = sid("session-a");
865        claim(&grants, "autonomous", &a).unwrap();
866        // Same session, same plugin — idempotent.
867        claim(&grants, "autonomous", &a).unwrap();
868        assert_eq!(grants.lock().unwrap().len(), 1);
869    }
870
871    #[test]
872    fn grants_cleared_on_reload_record() {
873        let grants: Grants = Default::default();
874        let a = sid("session-a");
875        let b = sid("session-b");
876        claim(&grants, "plugin1", &a).unwrap();
877        claim(&grants, "plugin2", &b).unwrap();
878        assert_eq!(grants.lock().unwrap().len(), 2);
879        // Simulated reload: all grants cleared.
880        clear_all(&grants);
881        assert!(grants.lock().unwrap().is_empty());
882        // Both can re-claim.
883        claim(&grants, "plugin1", &a).unwrap();
884        claim(&grants, "plugin2", &b).unwrap();
885    }
886
887    #[test]
888    fn release_all_for_session_leaves_other_sessions() {
889        let grants: Grants = Default::default();
890        let a = sid("session-a");
891        let b = sid("session-b");
892        claim(&grants, "plugin1", &a).unwrap();
893        claim(&grants, "plugin2", &b).unwrap();
894        release_all(&grants, &a);
895        assert_eq!(grants.lock().unwrap().len(), 1);
896        assert!(grants.lock().unwrap().contains_key("plugin2"));
897    }
898}