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