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
317 // Sub-agents are full agents with the full toolset (no per-role tool
318 // trimming): the child tool surface is the plain base tools.
319 let child_tools: Arc<dyn bamboo_agent_core::tools::ToolExecutor> = base_tools.clone();
320
321 // Unified agent runtime (shared resources for all execution paths).
322 // default_tools = base_tools (builtin + MCP + memory + skills) as a safe fallback.
323 // Interactive execution paths pass an explicit tool surface override:
324 // root sessions use ToolSurface::Root; child sessions use ToolSurface::Child.
325 let agent = Arc::new(
326 bamboo_engine::Agent::builder()
327 .storage(storage.clone())
328 .persistence(persistence.clone())
329 .attachment_reader(session_store.clone())
330 .skill_manager(skill_manager.clone())
331 .metrics_collector(metrics_service.collector())
332 .config(config.clone())
333 .provider(provider_handle.clone())
334 .default_tools(base_tools.clone())
335 .build()
336 .expect("agent runtime should be fully configured"),
337 );
338
339 let child_completion_coordinator =
340 Arc::new(bamboo_engine::ChildCompletionCoordinator::new(
341 storage.clone(),
342 persistence.clone(),
343 sessions.clone(),
344 agent_runners.clone(),
345 session_event_senders.clone(),
346 agent.clone(),
347 config.clone(),
348 provider_registry.clone(),
349 provider_router.clone(),
350 data_dir.clone(),
351 Some(account_sink.inbox()),
352 ));
353
354 // Initialize sub-session spawn scheduler (async background jobs).
355 let config_snapshot = config.read().await.clone();
356
357 // When a broker is configured, run the MCP proxy service under a
358 // supervisor (issue #47): deployed workers forward their (host-bound)
359 // MCP tool calls here, and we execute them against this orchestrator's
360 // real MCP servers (single MCP host). The supervisor restarts the proxy
361 // with bounded backoff after a transient WebSocket drop instead of
362 // permanently disabling proxied tools for the worker's lifetime.
363 let mcp_proxy_shutdown = tokio_util::sync::CancellationToken::new();
364 if let Some(broker) = config_snapshot.subagents.broker.clone() {
365 if !broker.endpoint.trim().is_empty() {
366 let backend: std::sync::Arc<dyn bamboo_agent_core::tools::ToolExecutor> =
367 std::sync::Arc::new(bamboo_mcp::executor::McpToolExecutor::new(
368 mcp_manager.clone(),
369 mcp_manager.tool_index(),
370 ));
371 let shutdown = mcp_proxy_shutdown.clone();
372
373 // Build the orchestrator-side per-role MCP tool allowlist from
374 // config (issue #54). Enforcement lives HERE — never in the
375 // worker-facing `McpProxyConfig` a deployed worker receives —
376 // because a worker self-declaring its own allowlist would be
377 // insecure (it could simply claim to be unrestricted). Validate
378 // configured tool names against THIS backend's real, live tool
379 // set so a typo in `config.json` is surfaced at boot instead of
380 // silently granting nothing for the intended tool.
381 let role_entries: Vec<(String, Vec<String>)> = config_snapshot
382 .subagents
383 .mcp_role_allowlist
384 .iter()
385 .map(|e| (e.role.clone(), e.tools.clone()))
386 .collect();
387 if role_entries.is_empty() {
388 // Default behavior unchanged (issue #54 item 5): no policy
389 // configured -> every role sees/can call every proxied
390 // tool. Logged once here at boot (not per-request) so an
391 // operator can tell the restriction is opt-in rather than
392 // silently absent.
393 tracing::info!(
394 "mcp proxy: no subagents.mcp_role_allowlist configured — every worker \
395 role sees/can call the full host-bound MCP tool set (opt in a role \
396 policy in config.json to scope tools per role; see issue #54)"
397 );
398 }
399 // NOTE: `init_mcp_manager` connects MCP servers in a background
400 // task so the HTTP API stays responsive at boot — this backend's
401 // `list_tools()` can legitimately be empty here if that task
402 // hasn't finished yet. `from_config` treats an empty set as "skip
403 // tool-name validation" rather than flagging every configured
404 // tool as an unknown typo, so warn separately here when that
405 // race means validation was effectively skipped.
406 let known_tools: std::collections::HashSet<String> = backend
407 .list_tools()
408 .into_iter()
409 .map(|t| t.function.name)
410 .collect();
411 if !role_entries.is_empty() && known_tools.is_empty() {
412 tracing::warn!(
413 "mcp role allowlist: the orchestrator's MCP tool set was empty at \
414 policy-load time (servers may still be connecting in the background) — \
415 skipped tool-name typo validation for subagents.mcp_role_allowlist"
416 );
417 }
418 let allowlist = std::sync::Arc::new(bamboo_broker::RoleToolAllowlist::from_config(
419 role_entries,
420 &known_tools,
421 ));
422 tokio::spawn(async move {
423 let me = bamboo_broker::AgentRef {
424 session_id: bamboo_broker::ORCHESTRATOR_ID.to_string(),
425 role: Some("orchestrator".into()),
426 };
427 bamboo_broker::serve_mcp_proxy_supervised(
428 &broker.endpoint,
429 me,
430 &broker.token,
431 backend,
432 allowlist,
433 shutdown,
434 )
435 .await;
436 });
437 }
438 }
439 let external_runner =
440 bamboo_engine::external_agents::runtime::build_external_child_runner(&config_snapshot);
441 let spawn_scheduler = build_spawn_scheduler(
442 agent.clone(),
443 child_tools,
444 sessions.clone(),
445 agent_runners.clone(),
446 session_event_senders.clone(),
447 external_runner,
448 Some(provider_router.clone()),
449 Some(child_completion_coordinator.clone()),
450 Some(data_dir.clone()),
451 Some(account_sink.inbox()),
452 );
453
454 let tools_with_task = base_tools.clone();
455
456 let schedule_store = init_schedule_store(&data_dir).await?;
457
458 // Bind the ledger's reminder bridge now that the schedule store exists.
459 ledger_schedule_bridge
460 .bind(Arc::new(crate::schedule_app::ScheduleLedgerBridge::new(
461 schedule_store.clone(),
462 )))
463 .await;
464
465 let schedule_manager = build_schedule_manager(
466 schedule_store.clone(),
467 agent.clone(),
468 tools_with_task.clone(),
469 sessions.clone(),
470 agent_runners.clone(),
471 session_event_senders.clone(),
472 persistence.clone(),
473 config.clone(),
474 provider_registry.clone(),
475 Some(data_dir.clone()),
476 Some(account_sink.inbox()),
477 notification_relay_deps.clone(),
478 );
479
480 bamboo_engine::auto_dream::spawn_auto_dream_task(
481 bamboo_engine::auto_dream::AutoDreamContext {
482 session_store: session_store.clone(),
483 storage: storage.clone(),
484 provider: provider_handle.clone(),
485 config: config.clone(),
486 provider_registry: provider_registry.clone(),
487 },
488 );
489
490 // Background memory "gardener": opt-in blob remediation + near-duplicate
491 // consolidation. No-op cost unless `memory.gardener_enabled` /
492 // `memory.dedup_gardener_enabled` is set; an empty prefilter makes zero LLM calls.
493 bamboo_engine::gardener::spawn_gardener_task(bamboo_engine::auto_dream::AutoDreamContext {
494 session_store: session_store.clone(),
495 storage: storage.clone(),
496 provider: provider_handle.clone(),
497 config: config.clone(),
498 provider_registry: provider_registry.clone(),
499 });
500
501 // Background ledger gardener: expiry + record↔schedule reconciliation
502 // are deterministic and free; distillation uses the background model
503 // and no-ops without one. The bridge handle is already bound above.
504 bamboo_engine::ledger_gardener::spawn_ledger_gardener_task(
505 bamboo_engine::ledger_gardener::LedgerGardenerContext {
506 dream: bamboo_engine::auto_dream::AutoDreamContext {
507 session_store: session_store.clone(),
508 storage: storage.clone(),
509 provider: provider_handle.clone(),
510 config: config.clone(),
511 provider_registry: provider_registry.clone(),
512 },
513 schedule_bridge: Some(ledger_schedule_bridge.clone()),
514 },
515 );
516
517 let config_for_resolver = config.clone();
518 let subagent_model_resolver: OptionalSubagentModelResolver = {
519 let registry = provider_registry.clone();
520 Some(Arc::new(
521 move |subagent_type: String| -> futures::future::BoxFuture<
522 'static,
523 Option<bamboo_domain::ProviderModelRef>,
524 > {
525 let config_for_resolver = config_for_resolver.clone();
526 let registry = registry.clone();
527 Box::pin(async move {
528 let config_snap = config_for_resolver.read().await.clone();
529 bamboo_engine::model_config_helper::resolve_subagent_model_ref(
530 &config_snap,
531 &config_snap.provider,
532 ®istry,
533 &subagent_type,
534 )
535 })
536 },
537 ))
538 };
539
540 // Config-write io-lock + the shared Remote Cluster Fabric deploy engine.
541 // The engine is built once and shared by the HTTP handlers (via AppState)
542 // and the `cluster` agent tool, so both use ONE worker registry.
543 let config_io_lock = Arc::new(tokio::sync::Mutex::new(()));
544 let fabric_registry: crate::tools::DeployedRegistry =
545 Arc::new(tokio::sync::Mutex::new(HashMap::new()));
546 let fabric_bamboo_bin =
547 std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("bamboo"));
548 let fabric_deployer = Arc::new(bamboo_server_tools::FabricDeployer::new(
549 config.clone(),
550 config_io_lock.clone(),
551 bamboo_home_dir.clone(),
552 fabric_registry,
553 fabric_bamboo_bin,
554 ));
555 // Cluster health monitor: periodically probe deployed workers on the bus and
556 // flip node status live (Running↔Unreachable) + auto-recover. Server-scoped
557 // — it runs under BOTH the embedded and an external broker (it reads the
558 // broker endpoint lazily each tick), and is aborted when the server drops.
559 let health_monitor = fabric_deployer
560 .clone()
561 .spawn_health_monitor()
562 .await
563 .map(HealthMonitor);
564
565 let tools = build_root_tools(
566 tools_with_task.clone(),
567 schedule_store.clone(),
568 schedule_manager.clone(),
569 session_store.clone(),
570 storage.clone(),
571 persistence.clone(),
572 spawn_scheduler.clone(),
573 sessions.clone(),
574 agent_runners.clone(),
575 session_event_senders.clone(),
576 subagent_model_resolver,
577 config.clone(),
578 provider_registry.clone(),
579 config_snapshot.subagents.broker.clone(),
580 fabric_deployer.clone(),
581 notification_relay_deps.clone(),
582 );
583
584 child_completion_coordinator
585 .set_root_tools(tools.clone())
586 .await;
587
588 let tool_factory =
589 crate::tools::ToolSurfaceFactory::new(base_tools, tools_with_task, tools);
590
591 let session_repo = bamboo_engine::SessionRepository::new(
592 sessions.clone(),
593 storage.clone(),
594 persistence.clone(),
595 );
596
597 // bamboo-connect (#452 / epic #447): drives bamboo sessions from IM
598 // platforms (Telegram first). Fully inert when `config.connect.platforms`
599 // is empty — mirrors the schedule manager / notification relay's
600 // always-constructed-but-only-active-if-configured lifecycle.
601 let connect_manager = Arc::new(
602 build_connect_manager(
603 agent.clone(),
604 tool_factory.get(crate::tools::ToolSurface::Root),
605 session_repo.clone(),
606 agent_runners.clone(),
607 session_event_senders.clone(),
608 Some(account_sink.inbox()),
609 Some(data_dir.clone()),
610 config.clone(),
611 provider_registry.clone(),
612 permission_checker.clone(),
613 )
614 .await,
615 );
616
617 // Dedicated child-session adapter backing the guardian review spawner.
618 // The guardian path passes an explicit model (no subagent_type routing)
619 // and registers its parent wait at the terminal gate (not via the
620 // adapter's coalescing slots), so a lightweight adapter with no resolver
621 // and a fresh wait-slot map suffices. `Arc<ChildSessionAdapter>` doubles
622 // as `Arc<dyn GuardianSpawner>`.
623 let child_adapter = Arc::new(crate::tools::ChildSessionAdapter {
624 session_store: session_store.clone(),
625 storage: storage.clone(),
626 persistence: persistence.clone(),
627 scheduler: spawn_scheduler.clone(),
628 sessions_cache: sessions.clone(),
629 agent_runners: agent_runners.clone(),
630 session_event_senders: session_event_senders.clone(),
631 subagent_model_resolver: None,
632 config: config.clone(),
633 parent_wait_slots: Arc::new(dashmap::DashMap::new()),
634 notification_relay: Some(notification_relay_deps.clone()),
635 });
636 let guardian_spawner: Arc<dyn bamboo_engine::GuardianSpawner> = child_adapter.clone();
637 // Wire the spawner into the completion coordinator too, so a resumed run
638 // can re-spawn a guardian to re-review a fix after a reject verdict.
639 child_completion_coordinator
640 .set_guardian_spawner(guardian_spawner.clone())
641 .await;
642
643 // The completion coordinator doubles as the bash self-resume hook
644 // (issue #84 Phase 2b): it polls the live shell registry and resumes a
645 // session once all its background bash shells finish.
646 let bash_resume_hook: Arc<dyn bamboo_engine::BashResumeHook> =
647 child_completion_coordinator.clone();
648
649 // Child-wait watchdog (issue #546): boot-time reconciliation of
650 // children orphaned by a restart, then a periodic heartbeat sweep that
651 // backstops every lost child→parent wake (panicked child task, dead
652 // spawn scheduler, clobbered resume, expired wait lease, ...). Spawned
653 // AFTER `set_root_tools` above so a boot-time parent resume can spawn.
654 child_completion_coordinator.spawn_child_wait_watchdog();
655
656 // Cluster-fabric reconcile: session-bound workers died with the previous
657 // bamboo process (kill-on-drop child / in-memory russh session), so any
658 // persisted `Running`/`Deploying` node state is stale on boot. Flip it to
659 // `Unreachable` so the UI/agent see reality (a redeploy brings it back).
660 reconcile_fabric_on_boot(&config, &bamboo_home_dir).await;
661
662 // `ServiceManager` (issue #479 / epic #477 prereq): supervises
663 // long-running "service" plugins. Always constructed — fully inert
664 // until a plugin install or the boot-time reconcile below starts
665 // something — mirrors `mcp_manager`/`connect_manager`'s
666 // always-alive lifecycle.
667 let service_manager = Arc::new(crate::service_manager::ServiceManager::new());
668 // Backgrounded (mirrors `init_mcp_manager`'s background MCP
669 // bootstrap): a service that `installed.json` says should be
670 // running but isn't (the previous `bamboo serve` process, if
671 // any, died with everything it supervised) is started fresh.
672 //
673 // The `JoinHandle` is kept (not discarded) purely so tests can
674 // deterministically wait it out via
675 // `AppState::wait_for_boot_reconcile_services` instead of racing
676 // this unsynchronized pass — see that method's doc comment and issue
677 // #486. Production code never awaits it; server startup is never
678 // blocked on plugin service spawns.
679 let boot_reconcile_services_handle = {
680 let service_manager = service_manager.clone();
681 let app_data_dir = bamboo_home_dir.clone();
682 tokio::spawn(async move {
683 crate::plugin_installer::boot_reconcile_services(&app_data_dir, &service_manager)
684 .await;
685 })
686 };
687
688 Ok(Self {
689 app_data_dir: bamboo_home_dir,
690 config,
691 config_io_lock,
692 fabric_deployer,
693 embedded_broker,
694 health_monitor,
695 provider: provider_lock,
696 provider_handle,
697 sessions,
698 storage,
699 session_store,
700 session_repo,
701 persistence,
702 spawn_scheduler,
703 child_completion_coordinator,
704 guardian_spawner,
705 bash_resume_hook,
706 schedule_store,
707 schedule_manager,
708 connect_manager,
709 tool_factory,
710 permission_checker,
711 notification_service,
712 session_watchers,
713 cancel_tokens: Arc::new(RwLock::new(HashMap::new())),
714 mcp_proxy_shutdown,
715 skill_manager,
716 mcp_manager,
717 service_manager,
718 boot_reconcile_services_handle: tokio::sync::Mutex::new(Some(
719 boot_reconcile_services_handle,
720 )),
721 metrics_service,
722 agent_runners,
723 session_event_senders,
724 account_sink,
725 process_registry,
726 metrics_bus: None, // Will be set by server if needed
727 agent,
728 provider_registry,
729 provider_router,
730 model_catalog,
731 title_gen_in_flight: Arc::new(dashmap::DashSet::new()),
732 pairing_codes: Arc::new(dashmap::DashMap::new()),
733 pairing_code_guard: Arc::new(crate::handlers::settings::PairingCodeGuard::default()),
734 root_password_guard: Arc::new(crate::handlers::settings::RootPasswordGuard::default()),
735 // remote-actor P2a (#181): empty in-memory agent registry.
736 })
737 }
738}
739
740/// A handle to the in-process mailbox bus (broker) so it can be shut down with
741/// the server. Dropping it aborts the serve task.
742pub struct EmbeddedBroker {
743 task: tokio::task::JoinHandle<()>,
744 gc_task: tokio::task::JoinHandle<()>,
745}
746
747impl Drop for EmbeddedBroker {
748 fn drop(&mut self) {
749 self.task.abort();
750 self.gc_task.abort();
751 }
752}
753
754/// Server-scoped handle to the cluster health monitor. Kept separate from
755/// [`EmbeddedBroker`] so the monitor runs under BOTH the embedded broker and an
756/// external (`broker.json`) one; aborts the sweep on drop.
757pub struct HealthMonitor(tokio::task::JoinHandle<()>);
758
759impl Drop for HealthMonitor {
760 fn drop(&mut self) {
761 self.0.abort();
762 }
763}
764
765/// Start an in-process broker on `127.0.0.1:<auto>` and point
766/// `config.subagents.broker` (RUNTIME-ONLY, `#[serde(skip)]`) at it — UNLESS a
767/// user-managed external broker is configured in `<data_dir>/broker.json` and
768/// reachable (then use that). Returns `None` when an external broker is used or
769/// the bind fails (sub-agent dispatch then degrades exactly as before).
770///
771/// The broker's endpoint deliberately lives in EITHER the in-memory config (for
772/// the embedded case, regenerated each boot) or its own `broker.json` (for the
773/// external case) — NEVER in `config.json`. That is what stops a prior run's
774/// ephemeral auto-port from leaking into the user's config and being dialed dead
775/// on the next boot (every sub-agent + the MCP proxy would hit "connect refused").
776async fn maybe_embed_broker(
777 config: &mut bamboo_llm::Config,
778 data_dir: &std::path::Path,
779) -> Option<EmbeddedBroker> {
780 // A user-managed EXTERNAL broker (multi-host / shared standalone bus) lives in
781 // its OWN file, `<data_dir>/broker.json` — separate from config.json. Honour
782 // it ONLY if actually REACHABLE; a dead endpoint (standalone broker not up)
783 // falls through to a fresh in-process broker so dispatch still works.
784 if let Some(external) = load_external_broker(data_dir) {
785 let endpoint = external.endpoint.trim().to_string();
786 if broker_endpoint_reachable(&endpoint).await {
787 tracing::info!(%endpoint, "using external broker from broker.json");
788 config.subagents.broker = Some(external);
789 return None;
790 }
791 tracing::warn!(
792 %endpoint,
793 "broker.json endpoint is unreachable — embedding a fresh in-process broker instead"
794 );
795 }
796
797 let listener = match tokio::net::TcpListener::bind("127.0.0.1:0").await {
798 Ok(l) => l,
799 Err(e) => {
800 tracing::warn!("embedded broker: bind failed, sub-agent dispatch disabled: {e}");
801 return None;
802 }
803 };
804 let port = match listener.local_addr() {
805 Ok(a) => a.port(),
806 Err(e) => {
807 tracing::warn!("embedded broker: local_addr failed: {e}");
808 return None;
809 }
810 };
811 let token = uuid::Uuid::new_v4().simple().to_string();
812 let root = data_dir.join("broker");
813 let core = Arc::new(bamboo_broker::BrokerCore::new(root));
814 // Reclaim orphan mailbox dirs (one-shot parent links, killed pool workers)
815 // every 5 min so `<data>/broker/mailboxes/` doesn't grow unbounded.
816 let gc_task = core
817 .clone()
818 .spawn_mailbox_gc(std::time::Duration::from_secs(300));
819 let server = Arc::new(bamboo_broker::BrokerServer::new(core, token.clone()));
820
821 let task = tokio::spawn(async move {
822 if let Err(e) = server.serve(listener).await {
823 tracing::error!("embedded broker serve loop ended: {e}");
824 }
825 });
826
827 // Set the endpoint IN MEMORY ONLY — `subagents.broker` is `#[serde(skip)]`, so
828 // this ephemeral loopback port is regenerated every boot and never touches disk.
829 config.subagents.broker = Some(bamboo_config::BrokerClientConfig {
830 endpoint: format!("ws://127.0.0.1:{port}"),
831 token,
832 token_encrypted: None,
833 });
834 tracing::info!(port, "embedded mailbox bus (broker) started in-process");
835 Some(EmbeddedBroker { task, gc_task })
836}
837
838/// Load a user-managed EXTERNAL broker from `<data_dir>/broker.json`, if present.
839/// This file is the SEPARATE, persisted home for a standalone/remote broker —
840/// deliberately NOT `config.json`, so the embedded broker's ephemeral runtime
841/// port can never leak into the user's config (the stale-dead-port bug). An
842/// absent file or a parse error yields `None` (embed a fresh in-process broker).
843///
844/// Format is a plain [`BrokerClientConfig`] JSON object, e.g.:
845/// `{ "endpoint": "wss://broker.example:9600", "token": "…" }`.
846fn load_external_broker(data_dir: &std::path::Path) -> Option<bamboo_config::BrokerClientConfig> {
847 let path = data_dir.join("broker.json");
848 let bytes = std::fs::read(&path).ok()?;
849 match serde_json::from_slice::<bamboo_config::BrokerClientConfig>(&bytes) {
850 Ok(cfg) if !cfg.endpoint.trim().is_empty() => Some(cfg),
851 Ok(_) => {
852 tracing::warn!(?path, "broker.json has an empty endpoint — ignoring");
853 None
854 }
855 Err(e) => {
856 tracing::warn!(?path, "broker.json present but unparseable: {e}");
857 None
858 }
859 }
860}
861
862/// Best-effort TCP reachability probe of a `ws[s]://host:port[/path]` broker
863/// endpoint. Used to tell a LIVE external/standalone broker (keep) apart from a
864/// DEAD persisted endpoint (a prior run's embedded auto-port that leaked into
865/// config.json — re-embed). A short timeout keeps boot fast when it's dead.
866async fn broker_endpoint_reachable(endpoint: &str) -> bool {
867 let host_port = endpoint
868 .trim()
869 .trim_start_matches("wss://")
870 .trim_start_matches("ws://")
871 .split('/')
872 .next()
873 .unwrap_or("");
874 if host_port.is_empty() {
875 return false;
876 }
877 matches!(
878 tokio::time::timeout(
879 std::time::Duration::from_millis(500),
880 tokio::net::TcpStream::connect(host_port),
881 )
882 .await,
883 Ok(Ok(_))
884 )
885}
886
887/// On boot, mark stale cluster-fabric node state as `Unreachable`: workers
888/// deployed by the previous process were session-bound (they died with it), so a
889/// persisted `Running`/`Deploying` status no longer reflects reality. Best-effort
890/// + persisted so the UI and `cluster status` don't show phantom-running nodes.
891async fn reconcile_fabric_on_boot(
892 config: &Arc<RwLock<bamboo_llm::Config>>,
893 data_dir: &std::path::Path,
894) {
895 use bamboo_config::cluster_fabric::NodeStatus;
896
897 let snapshot = {
898 let mut cfg = config.write().await;
899 let mut changed = 0usize;
900 for node in &mut cfg.cluster_fabric.nodes {
901 if let Some(state) = node.state.as_mut() {
902 if matches!(state.status, NodeStatus::Running | NodeStatus::Deploying) {
903 state.status = NodeStatus::Unreachable;
904 state.last_error =
905 Some("orchestrator restarted; worker no longer tracked".to_string());
906 changed += 1;
907 }
908 }
909 }
910 if changed == 0 {
911 return;
912 }
913 tracing::info!(
914 reconciled = changed,
915 "cluster-fabric: marked stale Running nodes Unreachable on boot"
916 );
917 cfg.clone()
918 };
919
920 if let Err(e) = snapshot.save_to_dir(data_dir.to_path_buf()) {
921 tracing::warn!("cluster-fabric boot reconcile: failed to persist: {e}");
922 }
923}
924
925#[cfg(test)]
926mod broker_embed_tests {
927 use super::{broker_endpoint_reachable, load_external_broker};
928
929 #[test]
930 fn load_external_broker_reads_broker_json_not_config() {
931 let dir = tempfile::tempdir().unwrap();
932 // Absent file ⇒ None (embed a fresh in-process broker).
933 assert!(load_external_broker(dir.path()).is_none());
934
935 // A well-formed broker.json ⇒ parsed external broker.
936 std::fs::write(
937 dir.path().join("broker.json"),
938 r#"{ "endpoint": "wss://broker.example:9600", "token": "t" }"#,
939 )
940 .unwrap();
941 let got = load_external_broker(dir.path()).expect("parsed");
942 assert_eq!(got.endpoint, "wss://broker.example:9600");
943 assert_eq!(got.token, "t");
944
945 // Empty endpoint ⇒ ignored (treated as absent).
946 std::fs::write(dir.path().join("broker.json"), r#"{ "endpoint": " " }"#).unwrap();
947 assert!(load_external_broker(dir.path()).is_none());
948
949 // Garbage ⇒ ignored, never panics.
950 std::fs::write(dir.path().join("broker.json"), "not json").unwrap();
951 assert!(load_external_broker(dir.path()).is_none());
952 }
953
954 #[tokio::test]
955 async fn reachability_probe_distinguishes_live_from_dead() {
956 // Bound-then-dropped port: nothing listening ⇒ unreachable (a stale
957 // persisted embedded endpoint must NOT be trusted).
958 let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
959 let dead = l.local_addr().unwrap();
960 drop(l);
961 assert!(!broker_endpoint_reachable(&format!("ws://{dead}")).await);
962
963 // Empty / malformed ⇒ never trusted.
964 assert!(!broker_endpoint_reachable("").await);
965 assert!(!broker_endpoint_reachable("ws://").await);
966
967 // A LIVE listener (with a path) ⇒ reachable (a real external broker is kept).
968 let live = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
969 let addr = live.local_addr().unwrap();
970 assert!(broker_endpoint_reachable(&format!("ws://{addr}/stream")).await);
971 }
972}