Skip to main content

bamboo_server/app_state/
builder.rs

1use super::init::{
2    build_connect_manager, build_provider_handles, build_schedule_manager, build_spawn_scheduler,
3    init_mcp_manager, init_metrics_service, init_schedule_store, init_skill_manager, init_storage,
4    load_permission_checker, spawn_session_map_cleanup_task,
5};
6use super::tools::{build_base_tools, build_root_tools};
7use super::*;
8use crate::tools::OptionalSubagentModelResolver;
9use bamboo_agent_core::storage::Storage;
10
11impl AppState {
12    /// Create unified app state with direct provider access
13    ///
14    /// This eliminates the proxy pattern where we created an AgentAppState
15    /// that called back to web_service via HTTP. Now we have direct provider access.
16    ///
17    /// # Arguments
18    ///
19    /// * `bamboo_home_dir` - Bamboo home directory containing all application data.
20    ///   This is the root directory (e.g., `${HOME}/.bamboo`) that contains:
21    ///   - config.json: Configuration file
22    ///   - sessions/: Conversation history
23    ///   - skills/: Skill definitions
24    ///   - workflows/: Workflow definitions
25    ///   - cache/: Cached data
26    ///   - runtime/: Runtime files
27    ///
28    /// # Returns
29    ///
30    /// A fully initialized AppState with all components ready for use.
31    /// # Example
32    ///
33    /// ```rust,no_run
34    /// use bamboo_server::app_state::AppState;
35    /// use std::path::PathBuf;
36    ///
37    /// #[tokio::main]
38    /// async fn main() {
39    ///     let state = AppState::new(PathBuf::from("/path/to/bamboo-data-dir"))
40    ///         .await
41    ///         .expect("failed to initialize app state");
42    ///     let provider = state.get_provider().await;
43    ///     let _models = provider.list_models().await.ok();
44    /// }
45    /// ```
46    pub async fn new(bamboo_home_dir: PathBuf) -> Result<Self, AppError> {
47        // Ensure all helpers that rely on `core::paths::bamboo_dir()` see the same
48        // directory as the server runtime.
49        bamboo_config::paths::init_bamboo_dir(bamboo_home_dir.clone());
50
51        // Load config from the specified data directory
52        let config = Config::from_data_dir(Some(bamboo_home_dir.clone()));
53
54        let provider_registry =
55            match bamboo_llm::ProviderRegistry::from_config(&config, bamboo_home_dir.clone()).await
56            {
57                Ok(registry) => Arc::new(registry),
58                Err(e) => {
59                    tracing::error!("Failed to create provider registry: {}", e);
60                    Arc::new(
61                        bamboo_llm::ProviderRegistry::from_config(
62                            &Config::default(),
63                            bamboo_home_dir.clone(),
64                        )
65                        .await
66                        .expect("Cannot create even an empty provider registry"),
67                    )
68                }
69            };
70
71        let provider = provider_registry.get_default().unwrap_or_else(|| {
72            let default_provider_name = provider_registry.default_provider_name();
73            let message = if config.has_provider_instances() {
74                format!(
75                    "Default provider instance '{}' is not available or failed to initialize",
76                    default_provider_name
77                )
78            } else {
79                format!(
80                    "Provider '{}' is not available or failed to initialize",
81                    config.provider
82                )
83            };
84            Arc::new(UnconfiguredProvider { message }) as Arc<dyn LLMProvider>
85        });
86
87        Self::new_with_provider(bamboo_home_dir, config, provider).await
88    }
89
90    /// Create unified app state with a specific provider
91    ///
92    /// Allows injecting a custom LLM provider instead of creating
93    /// one from configuration. Useful for testing and custom deployments.
94    ///
95    /// # Arguments
96    ///
97    /// * `bamboo_home_dir` - Bamboo home directory containing all application data
98    /// * `config` - Application configuration
99    /// * `provider` - Pre-configured LLM provider implementation
100    ///
101    /// # Returns
102    ///
103    /// A fully initialized AppState with the provided provider.
104    pub async fn new_with_provider(
105        bamboo_home_dir: PathBuf,
106        config: Config,
107        provider: Arc<dyn LLMProvider>,
108    ) -> Result<Self, AppError> {
109        // Wire the configured-default-workspace resolver into agent-core. This keeps
110        let data_dir = bamboo_home_dir.clone();
111        let (session_store, storage) = init_storage(&data_dir).await?;
112        let persistence = Arc::new(LockedSessionStore::new(storage.clone()));
113
114        // In-memory session cache (shared across handlers and background jobs).
115        let sessions: bamboo_engine::SessionCache = Arc::new(dashmap::DashMap::new());
116
117        // Embed the mailbox bus (broker) in-process unless an external one is
118        // configured. Mutates `config.subagents.broker` to point at the loopback
119        // bus BEFORE it is wrapped/read downstream, so ask_agent / deploy_agent /
120        // cluster all wire to it — and a standalone `bamboo broker serve` is no
121        // longer required for sub-agent dispatch. (Foundation for routing local
122        // actors onto the bus.)
123        let mut config = config;
124        let embedded_broker = maybe_embed_broker(&mut config, &data_dir).await;
125
126        let config = Arc::new(RwLock::new(config));
127
128        // Wire the configured-default-workspace resolver into agent-core. This keeps
129        // the dependency arrow pointing down (agent-core owns only the slot; the
130        // server fills it). The closure reads the server's LIVE in-memory config —
131        // not a fresh disk-reading Config::new(), which would diverge from the live
132        // config and clobber the global env-var cache (#38). `try_read` never blocks
133        // (the resolver is called from sync code, so a blocking read could deadlock);
134        // on the rare write-lock contention it returns the last successfully-resolved
135        // path so a session never transiently falls back to the process cwd.
136        {
137            let config_for_workspace = config.clone();
138            let last_known: Arc<std::sync::Mutex<Option<PathBuf>>> =
139                Arc::new(std::sync::Mutex::new(None));
140            bamboo_agent_core::workspace_state::set_default_workspace_provider(Box::new(
141                move || match config_for_workspace.try_read() {
142                    Ok(cfg) => {
143                        let path = cfg.get_default_work_area_path();
144                        if let Ok(mut cache) = last_known.lock() {
145                            *cache = path.clone();
146                        }
147                        path
148                    }
149                    Err(_) => last_known.lock().ok().and_then(|c| c.clone()),
150                },
151            ));
152        }
153
154        let permission_checker = load_permission_checker(&bamboo_home_dir).await;
155        let notification_service = Arc::new(bamboo_notification::NotificationService::new(
156            bamboo_home_dir.join("notification_preferences.json"),
157        ));
158        let session_watchers = super::watchers::SessionWatchers::new();
159        let mcp_manager = init_mcp_manager(config.clone());
160        let skill_manager = init_skill_manager(&data_dir).await;
161        let metrics_service = init_metrics_service(&data_dir).await?;
162
163        let startup_sessions = {
164            let entries = session_store.list_index_entries().await;
165            let mut sessions = Vec::new();
166            for entry in entries {
167                if let Some(session) = session_store
168                    .load_session(&entry.id)
169                    .await
170                    .map_err(AppError::StorageError)?
171                {
172                    sessions.push(session);
173                }
174            }
175            sessions
176        };
177        metrics_service
178            .reconcile_startup_sessions(startup_sessions, &[])
179            .await
180            .map_err(|error| {
181                AppError::InternalError(anyhow::anyhow!(
182                    "Failed to reconcile stale metrics state on startup: {error}"
183                ))
184            })?;
185
186        let agent_runners: Arc<RwLock<HashMap<String, AgentRunner>>> =
187            Arc::new(RwLock::new(HashMap::new()));
188        // NOTE: the idle-eviction sweep for `agent_runners` is spawned below,
189        // once `session_event_senders` also exists, so it can drop both maps'
190        // entries for a completed session together (issue #346).
191
192        let process_registry = Arc::new(ProcessRegistry::new());
193        let (provider_lock, provider_handle) = build_provider_handles(provider);
194
195        // Initialize multi-provider registry (for features.provider_model_ref).
196        let config_snapshot = config.read().await;
197        let provider_registry = match bamboo_llm::ProviderRegistry::from_config(
198            &config_snapshot,
199            bamboo_home_dir.clone(),
200        )
201        .await
202        {
203            Ok(registry) => Arc::new(registry),
204            Err(e) => {
205                tracing::error!("Failed to create provider registry: {}", e);
206                Arc::new(
207                    bamboo_llm::ProviderRegistry::from_config(
208                        &Config::default(),
209                        bamboo_home_dir.clone(),
210                    )
211                    .await
212                    .expect("Cannot create even an empty provider registry"),
213                )
214            }
215        };
216        drop(config_snapshot);
217
218        let provider_router = Arc::new(bamboo_llm::ProviderModelRouter::new(
219            provider_registry.clone(),
220        ));
221        let model_catalog = Arc::new(bamboo_llm::ModelCatalogService::new(
222            provider_registry.clone(),
223        ));
224
225        // Long-lived session event senders map (UI subscriptions + background tasks).
226        // Declared before `build_base_tools` (moved up from its original spot below)
227        // because the `notify` tool overlaid there needs it to broadcast onto a
228        // session's live channel — see `app_state::tools::build_base_tools`.
229        let session_event_senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>> =
230            Arc::new(RwLock::new(HashMap::new()));
231
232        // Shared bundle of always-on notification relay deps (see
233        // `session_events::NotificationRelayDeps`). Built once and cloned into
234        // every entry point that starts a relay directly at execution time —
235        // the schedule manager, the root child-session adapter, and the
236        // guardian child-session adapter below — so they can never drift.
237        let notification_relay_deps = crate::app_state::session_events::NotificationRelayDeps {
238            notification_service: notification_service.clone(),
239            session_event_senders: session_event_senders.clone(),
240            session_watchers: session_watchers.clone(),
241            config: config.clone(),
242        };
243
244        let base_tools = build_base_tools(
245            config.clone(),
246            permission_checker.clone(),
247            mcp_manager.clone(),
248            skill_manager.clone(),
249            storage.clone(),
250            persistence.clone(),
251            sessions.clone(),
252            bamboo_home_dir.clone(),
253            notification_service.clone(),
254            session_event_senders.clone(),
255            session_watchers.clone(),
256        );
257
258        // Idle-evict completed runners together with their paired session event
259        // senders (issue #346). Spawned here (not next to `agent_runners`) so it
260        // owns handles to both maps.
261        spawn_session_map_cleanup_task(agent_runners.clone(), session_event_senders.clone(), None);
262
263        // Account-scoped durable change feed. Opening the journal recovers the
264        // max seq so the sequence counter stays monotonic across restarts.
265        let account_sink = bamboo_engine::events::AccountEventSink::new(data_dir.join("events"))
266            .map_err(|e| {
267                AppError::InternalError(anyhow::anyhow!(
268                    "failed to initialize account change-feed journal: {e}"
269                ))
270            })?;
271
272        // Sub-agents are full agents with the full toolset (no per-role tool
273        // trimming): the child tool surface is the plain base tools.
274        let child_tools: Arc<dyn bamboo_agent_core::tools::ToolExecutor> = base_tools.clone();
275
276        // Unified agent runtime (shared resources for all execution paths).
277        // default_tools = base_tools (builtin + MCP + memory + skills) as a safe fallback.
278        // Interactive execution paths pass an explicit tool surface override:
279        // root sessions use ToolSurface::Root; child sessions use ToolSurface::Child.
280        let agent = Arc::new(
281            bamboo_engine::Agent::builder()
282                .storage(storage.clone())
283                .persistence(persistence.clone())
284                .attachment_reader(session_store.clone())
285                .skill_manager(skill_manager.clone())
286                .metrics_collector(metrics_service.collector())
287                .config(config.clone())
288                .provider(provider_handle.clone())
289                .default_tools(base_tools.clone())
290                .build()
291                .expect("agent runtime should be fully configured"),
292        );
293
294        let child_completion_coordinator =
295            Arc::new(bamboo_engine::ChildCompletionCoordinator::new(
296                storage.clone(),
297                persistence.clone(),
298                sessions.clone(),
299                agent_runners.clone(),
300                session_event_senders.clone(),
301                agent.clone(),
302                config.clone(),
303                provider_registry.clone(),
304                provider_router.clone(),
305                data_dir.clone(),
306                Some(account_sink.inbox()),
307            ));
308
309        // Initialize sub-session spawn scheduler (async background jobs).
310        let config_snapshot = config.read().await.clone();
311
312        // When a broker is configured, run the MCP proxy service under a
313        // supervisor (issue #47): deployed workers forward their (host-bound)
314        // MCP tool calls here, and we execute them against this orchestrator's
315        // real MCP servers (single MCP host). The supervisor restarts the proxy
316        // with bounded backoff after a transient WebSocket drop instead of
317        // permanently disabling proxied tools for the worker's lifetime.
318        let mcp_proxy_shutdown = tokio_util::sync::CancellationToken::new();
319        if let Some(broker) = config_snapshot.subagents.broker.clone() {
320            if !broker.endpoint.trim().is_empty() {
321                let backend: std::sync::Arc<dyn bamboo_agent_core::tools::ToolExecutor> =
322                    std::sync::Arc::new(bamboo_mcp::executor::McpToolExecutor::new(
323                        mcp_manager.clone(),
324                        mcp_manager.tool_index(),
325                    ));
326                let shutdown = mcp_proxy_shutdown.clone();
327                tokio::spawn(async move {
328                    let me = bamboo_broker::AgentRef {
329                        session_id: bamboo_broker::ORCHESTRATOR_ID.to_string(),
330                        role: Some("orchestrator".into()),
331                    };
332                    // No per-role tool allowlist is configured here yet, so the
333                    // proxy is unrestricted (every role sees/can call all
334                    // host-bound tools) — identical to the pre-#54 behavior.
335                    // Wiring a config-driven allowlist in is a follow-up; the
336                    // filtering mechanism (RoleToolAllowlist) is now in place.
337                    let allowlist =
338                        std::sync::Arc::new(bamboo_broker::RoleToolAllowlist::unrestricted());
339                    bamboo_broker::serve_mcp_proxy_supervised(
340                        &broker.endpoint,
341                        me,
342                        &broker.token,
343                        backend,
344                        allowlist,
345                        shutdown,
346                    )
347                    .await;
348                });
349            }
350        }
351        let external_runner =
352            bamboo_engine::external_agents::runtime::build_external_child_runner(&config_snapshot);
353        let spawn_scheduler = build_spawn_scheduler(
354            agent.clone(),
355            child_tools,
356            sessions.clone(),
357            agent_runners.clone(),
358            session_event_senders.clone(),
359            external_runner,
360            Some(provider_router.clone()),
361            Some(child_completion_coordinator.clone()),
362            Some(data_dir.clone()),
363            Some(account_sink.inbox()),
364        );
365
366        let tools_with_task = base_tools.clone();
367
368        let schedule_store = init_schedule_store(&data_dir).await?;
369        let schedule_manager = build_schedule_manager(
370            schedule_store.clone(),
371            agent.clone(),
372            tools_with_task.clone(),
373            sessions.clone(),
374            agent_runners.clone(),
375            session_event_senders.clone(),
376            persistence.clone(),
377            config.clone(),
378            provider_registry.clone(),
379            Some(data_dir.clone()),
380            Some(account_sink.inbox()),
381            notification_relay_deps.clone(),
382        );
383
384        bamboo_engine::auto_dream::spawn_auto_dream_task(
385            bamboo_engine::auto_dream::AutoDreamContext {
386                session_store: session_store.clone(),
387                storage: storage.clone(),
388                provider: provider_handle.clone(),
389                config: config.clone(),
390                provider_registry: provider_registry.clone(),
391            },
392        );
393
394        // Background memory "gardener": opt-in blob remediation + near-duplicate
395        // consolidation. No-op cost unless `memory.gardener_enabled` /
396        // `memory.dedup_gardener_enabled` is set; an empty prefilter makes zero LLM calls.
397        bamboo_engine::gardener::spawn_gardener_task(bamboo_engine::auto_dream::AutoDreamContext {
398            session_store: session_store.clone(),
399            storage: storage.clone(),
400            provider: provider_handle.clone(),
401            config: config.clone(),
402            provider_registry: provider_registry.clone(),
403        });
404
405        let config_for_resolver = config.clone();
406        let subagent_model_resolver: OptionalSubagentModelResolver = {
407            let registry = provider_registry.clone();
408            Some(Arc::new(
409                move |subagent_type: String| -> futures::future::BoxFuture<
410                    'static,
411                    Option<bamboo_domain::ProviderModelRef>,
412                > {
413                    let config_for_resolver = config_for_resolver.clone();
414                    let registry = registry.clone();
415                    Box::pin(async move {
416                        let config_snap = config_for_resolver.read().await.clone();
417                        bamboo_engine::model_config_helper::resolve_subagent_model_ref(
418                            &config_snap,
419                            &config_snap.provider,
420                            &registry,
421                            &subagent_type,
422                        )
423                    })
424                },
425            ))
426        };
427
428        // Config-write io-lock + the shared Remote Cluster Fabric deploy engine.
429        // The engine is built once and shared by the HTTP handlers (via AppState)
430        // and the `cluster` agent tool, so both use ONE worker registry.
431        let config_io_lock = Arc::new(tokio::sync::Mutex::new(()));
432        let fabric_registry: crate::tools::DeployedRegistry =
433            Arc::new(tokio::sync::Mutex::new(HashMap::new()));
434        let fabric_bamboo_bin =
435            std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("bamboo"));
436        let fabric_deployer = Arc::new(bamboo_server_tools::FabricDeployer::new(
437            config.clone(),
438            config_io_lock.clone(),
439            bamboo_home_dir.clone(),
440            fabric_registry,
441            fabric_bamboo_bin,
442        ));
443        // Cluster health monitor: periodically probe deployed workers on the bus and
444        // flip node status live (Running↔Unreachable) + auto-recover. Server-scoped
445        // — it runs under BOTH the embedded and an external broker (it reads the
446        // broker endpoint lazily each tick), and is aborted when the server drops.
447        let health_monitor = fabric_deployer
448            .clone()
449            .spawn_health_monitor()
450            .await
451            .map(HealthMonitor);
452
453        let tools = build_root_tools(
454            tools_with_task.clone(),
455            schedule_store.clone(),
456            schedule_manager.clone(),
457            session_store.clone(),
458            storage.clone(),
459            persistence.clone(),
460            spawn_scheduler.clone(),
461            sessions.clone(),
462            agent_runners.clone(),
463            session_event_senders.clone(),
464            subagent_model_resolver,
465            config.clone(),
466            provider_registry.clone(),
467            config_snapshot.subagents.broker.clone(),
468            fabric_deployer.clone(),
469            notification_relay_deps.clone(),
470        );
471
472        child_completion_coordinator
473            .set_root_tools(tools.clone())
474            .await;
475
476        let tool_factory =
477            crate::tools::ToolSurfaceFactory::new(base_tools, tools_with_task, tools);
478
479        let session_repo = bamboo_engine::SessionRepository::new(
480            sessions.clone(),
481            storage.clone(),
482            persistence.clone(),
483        );
484
485        // bamboo-connect (#452 / epic #447): drives bamboo sessions from IM
486        // platforms (Telegram first). Fully inert when `config.connect.platforms`
487        // is empty — mirrors the schedule manager / notification relay's
488        // always-constructed-but-only-active-if-configured lifecycle.
489        let connect_manager = Arc::new(
490            build_connect_manager(
491                agent.clone(),
492                tool_factory.get(crate::tools::ToolSurface::Root),
493                session_repo.clone(),
494                agent_runners.clone(),
495                session_event_senders.clone(),
496                Some(account_sink.inbox()),
497                Some(data_dir.clone()),
498                config.clone(),
499                provider_registry.clone(),
500            )
501            .await,
502        );
503
504        // Dedicated child-session adapter backing the guardian review spawner.
505        // The guardian path passes an explicit model (no subagent_type routing)
506        // and registers its parent wait at the terminal gate (not via the
507        // adapter's coalescing slots), so a lightweight adapter with no resolver
508        // and a fresh wait-slot map suffices. `Arc<ChildSessionAdapter>` doubles
509        // as `Arc<dyn GuardianSpawner>`.
510        let child_adapter = Arc::new(crate::tools::ChildSessionAdapter {
511            session_store: session_store.clone(),
512            storage: storage.clone(),
513            persistence: persistence.clone(),
514            scheduler: spawn_scheduler.clone(),
515            sessions_cache: sessions.clone(),
516            agent_runners: agent_runners.clone(),
517            session_event_senders: session_event_senders.clone(),
518            subagent_model_resolver: None,
519            config: config.clone(),
520            parent_wait_slots: Arc::new(dashmap::DashMap::new()),
521            notification_relay: Some(notification_relay_deps.clone()),
522        });
523        let guardian_spawner: Arc<dyn bamboo_engine::GuardianSpawner> = child_adapter.clone();
524        // Wire the spawner into the completion coordinator too, so a resumed run
525        // can re-spawn a guardian to re-review a fix after a reject verdict.
526        child_completion_coordinator
527            .set_guardian_spawner(guardian_spawner.clone())
528            .await;
529
530        // The completion coordinator doubles as the bash self-resume hook
531        // (issue #84 Phase 2b): it polls the live shell registry and resumes a
532        // session once all its background bash shells finish.
533        let bash_resume_hook: Arc<dyn bamboo_engine::BashResumeHook> =
534            child_completion_coordinator.clone();
535
536        // Cluster-fabric reconcile: session-bound workers died with the previous
537        // bamboo process (kill-on-drop child / in-memory russh session), so any
538        // persisted `Running`/`Deploying` node state is stale on boot. Flip it to
539        // `Unreachable` so the UI/agent see reality (a redeploy brings it back).
540        reconcile_fabric_on_boot(&config, &bamboo_home_dir).await;
541
542        Ok(Self {
543            app_data_dir: bamboo_home_dir,
544            config,
545            config_io_lock,
546            fabric_deployer,
547            embedded_broker,
548            health_monitor,
549            provider: provider_lock,
550            provider_handle,
551            sessions,
552            storage,
553            session_store,
554            session_repo,
555            persistence,
556            spawn_scheduler,
557            child_completion_coordinator,
558            guardian_spawner,
559            bash_resume_hook,
560            schedule_store,
561            schedule_manager,
562            connect_manager,
563            tool_factory,
564            permission_checker,
565            notification_service,
566            session_watchers,
567            cancel_tokens: Arc::new(RwLock::new(HashMap::new())),
568            mcp_proxy_shutdown,
569            skill_manager,
570            mcp_manager,
571            metrics_service,
572            agent_runners,
573            session_event_senders,
574            account_sink,
575            process_registry,
576            metrics_bus: None, // Will be set by server if needed
577            agent,
578            provider_registry,
579            provider_router,
580            model_catalog,
581            title_gen_in_flight: Arc::new(dashmap::DashSet::new()),
582            pairing_codes: Arc::new(dashmap::DashMap::new()),
583            pairing_code_guard: Arc::new(crate::handlers::settings::PairingCodeGuard::default()),
584            root_password_guard: Arc::new(crate::handlers::settings::RootPasswordGuard::default()),
585            // remote-actor P2a (#181): empty in-memory agent registry.
586        })
587    }
588}
589
590/// A handle to the in-process mailbox bus (broker) so it can be shut down with
591/// the server. Dropping it aborts the serve task.
592pub struct EmbeddedBroker {
593    task: tokio::task::JoinHandle<()>,
594    gc_task: tokio::task::JoinHandle<()>,
595}
596
597impl Drop for EmbeddedBroker {
598    fn drop(&mut self) {
599        self.task.abort();
600        self.gc_task.abort();
601    }
602}
603
604/// Server-scoped handle to the cluster health monitor. Kept separate from
605/// [`EmbeddedBroker`] so the monitor runs under BOTH the embedded broker and an
606/// external (`broker.json`) one; aborts the sweep on drop.
607pub struct HealthMonitor(tokio::task::JoinHandle<()>);
608
609impl Drop for HealthMonitor {
610    fn drop(&mut self) {
611        self.0.abort();
612    }
613}
614
615/// Start an in-process broker on `127.0.0.1:<auto>` and point
616/// `config.subagents.broker` (RUNTIME-ONLY, `#[serde(skip)]`) at it — UNLESS a
617/// user-managed external broker is configured in `<data_dir>/broker.json` and
618/// reachable (then use that). Returns `None` when an external broker is used or
619/// the bind fails (sub-agent dispatch then degrades exactly as before).
620///
621/// The broker's endpoint deliberately lives in EITHER the in-memory config (for
622/// the embedded case, regenerated each boot) or its own `broker.json` (for the
623/// external case) — NEVER in `config.json`. That is what stops a prior run's
624/// ephemeral auto-port from leaking into the user's config and being dialed dead
625/// on the next boot (every sub-agent + the MCP proxy would hit "connect refused").
626async fn maybe_embed_broker(
627    config: &mut bamboo_llm::Config,
628    data_dir: &std::path::Path,
629) -> Option<EmbeddedBroker> {
630    // A user-managed EXTERNAL broker (multi-host / shared standalone bus) lives in
631    // its OWN file, `<data_dir>/broker.json` — separate from config.json. Honour
632    // it ONLY if actually REACHABLE; a dead endpoint (standalone broker not up)
633    // falls through to a fresh in-process broker so dispatch still works.
634    if let Some(external) = load_external_broker(data_dir) {
635        let endpoint = external.endpoint.trim().to_string();
636        if broker_endpoint_reachable(&endpoint).await {
637            tracing::info!(%endpoint, "using external broker from broker.json");
638            config.subagents.broker = Some(external);
639            return None;
640        }
641        tracing::warn!(
642            %endpoint,
643            "broker.json endpoint is unreachable — embedding a fresh in-process broker instead"
644        );
645    }
646
647    let listener = match tokio::net::TcpListener::bind("127.0.0.1:0").await {
648        Ok(l) => l,
649        Err(e) => {
650            tracing::warn!("embedded broker: bind failed, sub-agent dispatch disabled: {e}");
651            return None;
652        }
653    };
654    let port = match listener.local_addr() {
655        Ok(a) => a.port(),
656        Err(e) => {
657            tracing::warn!("embedded broker: local_addr failed: {e}");
658            return None;
659        }
660    };
661    let token = uuid::Uuid::new_v4().simple().to_string();
662    let root = data_dir.join("broker");
663    let core = Arc::new(bamboo_broker::BrokerCore::new(root));
664    // Reclaim orphan mailbox dirs (one-shot parent links, killed pool workers)
665    // every 5 min so `<data>/broker/mailboxes/` doesn't grow unbounded.
666    let gc_task = core
667        .clone()
668        .spawn_mailbox_gc(std::time::Duration::from_secs(300));
669    let server = Arc::new(bamboo_broker::BrokerServer::new(core, token.clone()));
670
671    let task = tokio::spawn(async move {
672        if let Err(e) = server.serve(listener).await {
673            tracing::error!("embedded broker serve loop ended: {e}");
674        }
675    });
676
677    // Set the endpoint IN MEMORY ONLY — `subagents.broker` is `#[serde(skip)]`, so
678    // this ephemeral loopback port is regenerated every boot and never touches disk.
679    config.subagents.broker = Some(bamboo_config::BrokerClientConfig {
680        endpoint: format!("ws://127.0.0.1:{port}"),
681        token,
682        token_encrypted: None,
683    });
684    tracing::info!(port, "embedded mailbox bus (broker) started in-process");
685    Some(EmbeddedBroker { task, gc_task })
686}
687
688/// Load a user-managed EXTERNAL broker from `<data_dir>/broker.json`, if present.
689/// This file is the SEPARATE, persisted home for a standalone/remote broker —
690/// deliberately NOT `config.json`, so the embedded broker's ephemeral runtime
691/// port can never leak into the user's config (the stale-dead-port bug). An
692/// absent file or a parse error yields `None` (embed a fresh in-process broker).
693///
694/// Format is a plain [`BrokerClientConfig`] JSON object, e.g.:
695/// `{ "endpoint": "wss://broker.example:9600", "token": "…" }`.
696fn load_external_broker(data_dir: &std::path::Path) -> Option<bamboo_config::BrokerClientConfig> {
697    let path = data_dir.join("broker.json");
698    let bytes = std::fs::read(&path).ok()?;
699    match serde_json::from_slice::<bamboo_config::BrokerClientConfig>(&bytes) {
700        Ok(cfg) if !cfg.endpoint.trim().is_empty() => Some(cfg),
701        Ok(_) => {
702            tracing::warn!(?path, "broker.json has an empty endpoint — ignoring");
703            None
704        }
705        Err(e) => {
706            tracing::warn!(?path, "broker.json present but unparseable: {e}");
707            None
708        }
709    }
710}
711
712/// Best-effort TCP reachability probe of a `ws[s]://host:port[/path]` broker
713/// endpoint. Used to tell a LIVE external/standalone broker (keep) apart from a
714/// DEAD persisted endpoint (a prior run's embedded auto-port that leaked into
715/// config.json — re-embed). A short timeout keeps boot fast when it's dead.
716async fn broker_endpoint_reachable(endpoint: &str) -> bool {
717    let host_port = endpoint
718        .trim()
719        .trim_start_matches("wss://")
720        .trim_start_matches("ws://")
721        .split('/')
722        .next()
723        .unwrap_or("");
724    if host_port.is_empty() {
725        return false;
726    }
727    matches!(
728        tokio::time::timeout(
729            std::time::Duration::from_millis(500),
730            tokio::net::TcpStream::connect(host_port),
731        )
732        .await,
733        Ok(Ok(_))
734    )
735}
736
737/// On boot, mark stale cluster-fabric node state as `Unreachable`: workers
738/// deployed by the previous process were session-bound (they died with it), so a
739/// persisted `Running`/`Deploying` status no longer reflects reality. Best-effort
740/// + persisted so the UI and `cluster status` don't show phantom-running nodes.
741async fn reconcile_fabric_on_boot(
742    config: &Arc<RwLock<bamboo_llm::Config>>,
743    data_dir: &std::path::Path,
744) {
745    use bamboo_config::cluster_fabric::NodeStatus;
746
747    let snapshot = {
748        let mut cfg = config.write().await;
749        let mut changed = 0usize;
750        for node in &mut cfg.cluster_fabric.nodes {
751            if let Some(state) = node.state.as_mut() {
752                if matches!(state.status, NodeStatus::Running | NodeStatus::Deploying) {
753                    state.status = NodeStatus::Unreachable;
754                    state.last_error =
755                        Some("orchestrator restarted; worker no longer tracked".to_string());
756                    changed += 1;
757                }
758            }
759        }
760        if changed == 0 {
761            return;
762        }
763        tracing::info!(
764            reconciled = changed,
765            "cluster-fabric: marked stale Running nodes Unreachable on boot"
766        );
767        cfg.clone()
768    };
769
770    if let Err(e) = snapshot.save_to_dir(data_dir.to_path_buf()) {
771        tracing::warn!("cluster-fabric boot reconcile: failed to persist: {e}");
772    }
773}
774
775#[cfg(test)]
776mod broker_embed_tests {
777    use super::{broker_endpoint_reachable, load_external_broker};
778
779    #[test]
780    fn load_external_broker_reads_broker_json_not_config() {
781        let dir = tempfile::tempdir().unwrap();
782        // Absent file ⇒ None (embed a fresh in-process broker).
783        assert!(load_external_broker(dir.path()).is_none());
784
785        // A well-formed broker.json ⇒ parsed external broker.
786        std::fs::write(
787            dir.path().join("broker.json"),
788            r#"{ "endpoint": "wss://broker.example:9600", "token": "t" }"#,
789        )
790        .unwrap();
791        let got = load_external_broker(dir.path()).expect("parsed");
792        assert_eq!(got.endpoint, "wss://broker.example:9600");
793        assert_eq!(got.token, "t");
794
795        // Empty endpoint ⇒ ignored (treated as absent).
796        std::fs::write(dir.path().join("broker.json"), r#"{ "endpoint": "  " }"#).unwrap();
797        assert!(load_external_broker(dir.path()).is_none());
798
799        // Garbage ⇒ ignored, never panics.
800        std::fs::write(dir.path().join("broker.json"), "not json").unwrap();
801        assert!(load_external_broker(dir.path()).is_none());
802    }
803
804    #[tokio::test]
805    async fn reachability_probe_distinguishes_live_from_dead() {
806        // Bound-then-dropped port: nothing listening ⇒ unreachable (a stale
807        // persisted embedded endpoint must NOT be trusted).
808        let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
809        let dead = l.local_addr().unwrap();
810        drop(l);
811        assert!(!broker_endpoint_reachable(&format!("ws://{dead}")).await);
812
813        // Empty / malformed ⇒ never trusted.
814        assert!(!broker_endpoint_reachable("").await);
815        assert!(!broker_endpoint_reachable("ws://").await);
816
817        // A LIVE listener (with a path) ⇒ reachable (a real external broker is kept).
818        let live = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
819        let addr = live.local_addr().unwrap();
820        assert!(broker_endpoint_reachable(&format!("ws://{addr}/stream")).await);
821    }
822}