Skip to main content

agent_engine/
host.rs

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