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