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}