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}