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