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