agentmux_srv/bootstrap/background.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Split out of the single-file bootstrap module unchanged (SPEC_LARGE_FILE_MODULE_ANALYSIS_2026_09_30.md §4.3).
5
6use super::*;
7
8/// Output of [`spawn_background_subsystems`] — event/watcher infrastructure
9/// that needs no `AppState` and can start before it is built.
10pub struct BackgroundSubsystems {
11 pub event_bus: Arc<EventBus>,
12 pub broker: Arc<Broker>,
13 pub editor_file_watcher: Arc<backend::editor_file_watcher::EditorFileWatcher>,
14 pub media_file_watcher: Arc<backend::media_file_watcher::MediaFileWatcher>,
15 pub files_watcher: Arc<backend::files_watcher::FilesWatcher>,
16 pub fs_watch_pool: Arc<backend::fs_watch::FsWatchPool>,
17 pub config_watcher: Arc<wconfig::ConfigState>,
18 pub reactive_handler: &'static reactive::ReactiveHandler,
19 pub poller: Arc<Poller>,
20 pub messagebus: Arc<backend::messagebus::MessageBus>,
21 pub subagent_watcher: Arc<backend::subagent_watcher::SubagentWatcher>,
22 pub history_service: Arc<backend::history::HistoryService>,
23}
24
25/// Event infrastructure + all background task spawns that don't need
26/// `AppState` to exist yet: event bus/broker, editor file watcher, config
27/// watcher, sysinfo/watchdog/activity loops, the reactive handler + poller,
28/// the cloud push subscriber, messaging bridges (Discord/Telegram/Slack/
29/// WhatsApp), docsite dir, messagebus, subagent watcher, history service,
30/// and the session archiver.
31pub fn spawn_background_subsystems(
32 mstore: &Arc<Store>,
33 filestore: &Arc<FileStore>,
34 id_store: &Arc<Store>,
35 identity_store: &Arc<Store>,
36) -> BackgroundSubsystems {
37 // Event infrastructure
38 let event_bus = Arc::new(EventBus::new());
39 let broker = Arc::new(Broker::new());
40
41 // Bridge MPS events to WebSocket clients via EventBus
42 let bridge = backend::eventbus::EventBusBridge::new(event_bus.clone());
43 broker.set_client(Box::new(bridge));
44 // Remote connections' status changes, from wherever a connection is used.
45 backend::remote::link::init(broker.clone());
46
47 // Shared filesystem-watcher framework — constructed once, before any
48 // consumer, all three of which now build on it (see
49 // SPEC_SHARED_FS_WATCHER_FRAMEWORK_2026_08_07.md §5's migration order:
50 // config_watcher_fs first, editor+media here, native memory as a future
51 // consumer).
52 let fs_watch_pool = backend::fs_watch::FsWatchPool::new();
53
54 // Watches files open in editor/preview panes, publishing a per-block
55 // wake signal on external changes. See SPEC_EDITOR_LIVE_FILE_RELOAD_2026_07_18.md.
56 let editor_file_watcher = backend::editor_file_watcher::EditorFileWatcher::new(fs_watch_pool.clone(), broker.clone());
57
58 // Watches directories a Media pane is pointed at, publishing a per-block
59 // wake signal when a matching-extension file changes. See
60 // SPEC_MEDIA_PANE_2026_07_26.md.
61 let media_file_watcher = backend::media_file_watcher::MediaFileWatcher::new(fs_watch_pool.clone(), broker.clone());
62
63 // Watches folders open in Files panes (`fs.watch`), publishing a
64 // per-block "re-list this folder" signal. See
65 // SPEC_FILE_BROWSER_PANE_2026_10_01.md §6.3.
66 let files_watcher = backend::files_watcher::FilesWatcher::new(fs_watch_pool.clone(), broker.clone());
67
68 // Deploy shell integration scripts (muxlog.mjs/muxspect.mjs + rcfiles)
69 // unconditionally at startup, not opportunistically from a specific
70 // controller's spawn path. Previously the only call site was
71 // `ShellController::start`'s interactive (empty-command) branch
72 // (`blockcontroller/shell/lifecycle.rs`) — a user whose first-ever pane
73 // in a fresh data dir is an Agent pane (persistent/subprocess/acp, which
74 // never hits that branch) would never get the scripts deployed at all,
75 // so even muxspect's own documented `node ~/.agentmux/shell/muxspect.mjs`
76 // direct-path fallback would ENOENT (codex P1 on PR #2380 — a latent gap
77 // in muxlog's identical deployment mechanism that PR newly depends on).
78 // `deploy_scripts` is idempotent (skips if its version marker already
79 // matches), so calling it here in addition to the existing call site is
80 // safe, not a double-write race.
81 // Two roots, deliberately:
82 // * the per-instance root the shells actually source (isolation, I6), and
83 // * the stable `~/.agentmux/shell` path docs/MUXSPECT.md and docs/MUXSH.md
84 // tell users to invoke directly, which must stay predictable.
85 backend::shellintegration::deploy_scripts(&backend::shellintegration::integration_base());
86 backend::shellintegration::deploy_scripts(&backend::shellintegration::documented_shell_root());
87
88 // Config watcher (created before sysinfo loop so it can read telemetry:interval)
89 let config_watcher = Arc::new(wconfig::ConfigState::with_config(wconfig::build_default_config()));
90
91 // Load user's settings.json from disk (merges with defaults)
92 backend::config_watcher_fs::load_settings_from_disk(&config_watcher);
93
94 // Watch settings.json for changes and broadcast to WebSocket clients
95 backend::config_watcher_fs::spawn_settings_watcher(
96 fs_watch_pool.clone(),
97 config_watcher.clone(),
98 event_bus.clone(),
99 );
100
101 // The user's own widgets.json beside settings.json, merged over the
102 // built-in widgets (Pane Tab contract Phase 6: where an `ext:` widget is
103 // added). Same load-then-watch shape, same pool.
104 backend::user_widgets::load_user_widgets_from_disk(&config_watcher);
105 backend::user_widgets::spawn_user_widgets_watcher(
106 fs_watch_pool.clone(),
107 config_watcher.clone(),
108 event_bus.clone(),
109 broker.clone(),
110 );
111
112 // Browser pane start page — same load-then-watch shape as settings.json
113 // above, on the SAME fs_watch_pool instance, so it rides the existing
114 // GetFullConfig/live-broadcast pipeline instead of a parallel one. See
115 // docs/specs/SPEC_BROWSER_PANE_START_PAGE_2026_09_16.md §3.2.
116 backend::browser_start_page::load_start_page_from_disk(&config_watcher);
117 backend::browser_start_page::spawn_start_page_watcher(
118 fs_watch_pool.clone(),
119 config_watcher.clone(),
120 event_bus.clone(),
121 );
122
123 // Start sysinfo collection loop (interval configurable via telemetry:interval)
124 let sysinfo_broker = broker.clone();
125 let sysinfo_config = config_watcher.clone();
126 let label_store = Arc::clone(mstore);
127 let pane_sampler = backend::sampler::panes::tower_sampler(move |id| {
128 backend::tower_sampler::block_label(&label_store, id)
129 });
130 tokio::spawn(async move {
131 sysinfo::run_sysinfo_loop(sysinfo_broker, sysinfo_config, "local".to_string(), pane_sampler).await;
132 });
133
134 // Start agent process watchdog (kills panes that exceed max-runtime or idle-output limits)
135 let watchdog_config = config_watcher.clone();
136 tokio::spawn(async move {
137 backend::blockcontroller::watchdog::run_watchdog_loop(watchdog_config).await;
138 });
139
140 // Recover a missing session title for a running agent (the Swarm row and
141 // pane header read it); see activity_watcher.rs —
142 // reads reactive::get_global_handler() as its registry, so it needs no
143 // AppState and can start before AppState is built (matches sysinfo/watchdog above).
144 let activity_mstore = Arc::clone(mstore);
145 let activity_filestore = Arc::clone(filestore);
146 let activity_event_bus = event_bus.clone();
147 // Every ambient call's spend goes to the status bar's totals (ambient/spend.rs).
148 crate::ambient::spend::publish_on(event_bus.clone());
149 tokio::spawn(async move {
150 backend::reactive::activity_watcher::run_agent_summary_loop(
151 activity_mstore, activity_filestore, activity_event_bus,
152 ).await;
153 });
154
155 // Push each agent's todo checklist + in-flight tool (swarm rows under the
156 // agent name). Separate loop from the summary above deliberately: this one
157 // is a tail-read + JSON parse with no model call, so it sweeps every 3s
158 // instead of 20s — a checklist that lags is worse than none. Same
159 // registry, same no-AppState property.
160 let progress_filestore = Arc::clone(filestore);
161 let progress_broker = broker.clone();
162 tokio::spawn(async move {
163 backend::reactive::progress_watcher::run_agent_progress_loop(
164 progress_filestore, progress_broker,
165 ).await;
166 });
167
168 // Keep each agent's repository, branch and uncommitted files fresh for
169 // WhoIsWorkingOn and ListConversations (backend/work_facts/git.rs): git
170 // runs here, never on a request.
171 let work_facts_mstore = Arc::clone(mstore);
172 tokio::spawn(async move {
173 backend::work_facts::git::run_git_refresh_loop(work_facts_mstore).await;
174 });
175
176 // Reactive handler (global singleton) + poller
177 let reactive_handler = reactive::get_global_handler();
178 reactive_handler.set_input_sender(Arc::new(|block_id: &str, data: &[u8]| {
179 backend::blockcontroller::send_input(
180 block_id,
181 backend::blockcontroller::BlockInputUnion::data(data.to_vec()),
182 None,
183 )
184 }));
185 // Controller-aware delivery (SPEC_AGENT_CONTROL_PROTOCOL §6 / Phase 3): persistent
186 // stream-json and ACP agents have no PTY, so muxbus Tier-1 keystroke injection
187 // silently misses them. Route those through their structured channel (live stdin /
188 // session/prompt) — which also steers the agent mid-turn — and fall back to PTY
189 // keystrokes only for terminal-based agents.
190 reactive_handler.set_message_sender(Arc::new(|block_id: &str, message: &str| {
191 match backend::blockcontroller::deliver_agent_message(block_id, message) {
192 Ok(backend::blockcontroller::AgentDelivery::Structured) => Ok(reactive::SenderDelivery::Delivered),
193 Ok(backend::blockcontroller::AgentDelivery::StructuredDeferred) => Ok(reactive::SenderDelivery::Deferred),
194 Ok(backend::blockcontroller::AgentDelivery::Pty) => Ok(reactive::SenderDelivery::Pty),
195 Err(e) => Err(e),
196 }
197 }));
198 // Recipient-identity check (issue #2695): before delivering, compare the
199 // resolved target's own live, spawn-time-captured identity (queried from
200 // the actual controller, independent of reactive_handler's own
201 // name/UID-to-block maps) against who the jekt was addressed to. See
202 // Controller::agent_id's doc comment for why this can't be derived from
203 // reactive_handler's own state.
204 reactive_handler.set_agent_identity_confirmer(Arc::new(|block_id: &str| {
205 backend::blockcontroller::get_controller(block_id).and_then(|c| c.agent_id())
206 }));
207 // Stable counterpart to the above (`AGENTMUX_AGENT_ID`, frozen at spawn)
208 // — see `Controller::stable_agent_id`'s and `NameBindings::stable`'s
209 // doc comments for why a jekt tagged with the stable ID needs its own,
210 // independently-sourced confirmer instead of reusing the live one.
211 reactive_handler.set_stable_agent_identity_confirmer(Arc::new(|block_id: &str| {
212 backend::blockcontroller::get_controller(block_id).and_then(|c| c.stable_agent_id())
213 }));
214 // Identity M2: the UID confirmer, consulted only for targets that
215 // resolved by UID — see `Handler::uid_identity_confirmer` for why it is
216 // a separate check and why `None` must mean "unverifiable".
217 reactive_handler.set_uid_identity_confirmer(Arc::new(|block_id: &str| {
218 backend::blockcontroller::get_controller(block_id).and_then(|c| c.stable_agent_uid())
219 }));
220 // Identity M2: liveness — the garbage collector that replaces
221 // eviction-by-name (spec §4.4.4 Q9). A block with no controller is dead
222 // and is swept when a name it held is resolved.
223 reactive_handler.set_block_liveness(Arc::new(|block_id: &str| {
224 backend::blockcontroller::get_controller(block_id).is_some()
225 }));
226 let poller = Arc::new(Poller::new(
227 PollerConfig {
228 muxbus_url: None,
229 muxbus_token: None,
230 poll_interval_secs: reactive::DEFAULT_POLL_INTERVAL_SECS,
231 },
232 reactive_handler,
233 ));
234
235 // Cloud delivery's state for the UI, the sign-in notification and the
236 // agents' pause/resume notes. Reads nothing from the keychain itself.
237 crate::muxbus::delivery_status::install(event_bus.clone(), broker.clone());
238 // Cloud push subscriber — single WS connection per sidecar that the cloud
239 // uses to push reactive injections instead of polling. The WS connection
240 // itself is a no-op until the user connects via muxbus.login, but
241 // `init_global` registers the muxbus credential with the broker
242 // scheduler unconditionally, which calls `Store::muxbus_is_fresh()` —
243 // a real, synchronous OS-keychain read — almost immediately. That read
244 // is the automatic (non-user-triggered) keychain touch
245 // docs/retro/retro-macos-muxbus-keychain-prompt-storm-2026-08-19.md
246 // investigates; skipping `init_global` entirely is not a no-op the way
247 // the comment above might suggest.
248 //
249 // `AGENTMUX_DISABLE_CLOUD_SUBSCRIBER` exists so `crates/srv/tests/
250 // integration_test.rs` can spawn the real binary without that read
251 // firing. Those tests spawn an ad-hoc/dev-signed `target/debug`
252 // binary, which is never on the Keychain ACL's trusted-signature list
253 // (only specific notarized, Developer-ID-signed installs are) — so
254 // every spawned test process gets its own interactive consent prompt
255 // for the SAME real credential, on whatever macOS account happens to
256 // be running the test. Confirmed live: a `cargo test -p agentmux-srv`
257 // run produced 4 near-simultaneous prompts, one per spawned subprocess,
258 // on a shared dev machine. Off by default — the shipped app and `task
259 // dev` must keep muxbus reconnect-on-launch working exactly as before.
260 //
261 // `isolated_muxbus_reconnect_enabled()` closes a second, broader gap
262 // the env-var flag above doesn't (see
263 // docs/retro/retro-macos-0560-stale-cef-cache-launch-crash-2026-09-16.md):
264 // every local `task package`/`task package:macos`/`task package:linux`
265 // build bakes a brand-new, randomized per-build channel (and, on
266 // macOS, a bundle identifier derived from it) into the binary, so it
267 // is a never-before-seen code signature to the Keychain every single
268 // time — the SAME already-`Always Allow`'d muxbus credential prompts
269 // again on every fresh local build, indefinitely. Unlike
270 // `isolated_auth_enabled`/`isolated_settings_enabled` (which also
271 // isolate `task dev`'s `dev-<branch>` channel), this flag deliberately
272 // exempts `dev-*` — see its own doc comment for why a per-branch-stable
273 // dev channel doesn't have the per-build-random-identity problem this
274 // one exists to solve. Skipping this eager call is also NOT permanent
275 // for the process's lifetime on an isolated channel: `muxbus.login`
276 // lazily initializes the subscriber the moment the user explicitly
277 // logs in (see `muxbus_handlers.rs`), and `muxbus.status` gates on
278 // subscriber presence rather than re-deriving this same channel check,
279 // so it starts working immediately after that login with no restart
280 // required.
281 let muxbus_reason = agentmux_common::isolated_muxbus_reconnect_reason();
282 if cloud_subscriber_disabled_from_env() {
283 tracing::info!("cloud_subscriber: init_global skipped (AGENTMUX_DISABLE_CLOUD_SUBSCRIBER)");
284 } else if muxbus_reason.is_isolated() {
285 tracing::info!(
286 reason = muxbus_reason.as_str(),
287 "cloud_subscriber: init_global skipped (ISOLATED — channel-scoped, no automatic MuxBus reconnect)"
288 );
289 } else {
290 crate::muxbus::cloud_subscriber::CloudSubscriber::init_global(id_store.clone());
291 }
292 // W3-S D1b: publish each agent's instance-certified WAN key to the
293 // account's cloud directory. Not gated on the subscriber: a logged-out
294 // or isolated channel simply finds no token and publishes nothing.
295 crate::muxbus::wan_publish::spawn(mstore.clone(), id_store.clone());
296
297 // Discord messaging bridge — connects to Discord Gateway if configured.
298 // Set messaging:discord:enabled + messaging:discord:token in settings.json to activate.
299 {
300 let settings = config_watcher.get_settings();
301 if settings.messaging_discord_enabled {
302 match settings.messaging_discord_token.clone() {
303 Some(token) if !token.is_empty() => {
304 messaging::discord::DiscordBridge::init_global(
305 messaging::discord::DiscordConfig {
306 token,
307 channel_id: settings.messaging_discord_channel.clone(),
308 target_agent: settings.messaging_discord_target.clone(),
309 guild_id: settings.messaging_discord_guild.clone(),
310 },
311 reqwest::Client::new(),
312 );
313 }
314 _ => {
315 tracing::warn!(
316 "discord bridge: enabled but messaging:discord:token is not set in settings.json"
317 );
318 }
319 }
320 }
321 }
322
323 // Telegram messaging bridge — long-polls getUpdates if configured.
324 // Set messaging:telegram:enabled + messaging:telegram:token in settings.json to activate.
325 {
326 let settings = config_watcher.get_settings();
327 if settings.messaging_telegram_enabled {
328 match settings.messaging_telegram_token.clone() {
329 Some(token) if !token.is_empty() => {
330 let allowed_chat_ids = settings
331 .messaging_telegram_allowed_chats
332 .split(',')
333 .filter_map(|s| s.trim().parse::<i64>().ok())
334 .collect::<Vec<_>>();
335 let default_chat_id = settings
336 .messaging_telegram_default_chat
337 .as_deref()
338 .and_then(|s| s.parse::<i64>().ok());
339 messaging::telegram::TelegramBridge::init_global(
340 messaging::telegram::TelegramConfig {
341 token,
342 allowed_chat_ids,
343 default_chat_id,
344 target_agent: settings.messaging_telegram_target.clone(),
345 },
346 reqwest::Client::new(),
347 );
348 }
349 _ => {
350 tracing::warn!(
351 "telegram bridge: enabled but messaging:telegram:token is not set in settings.json"
352 );
353 }
354 }
355 }
356 }
357
358 // Slack messaging bridge — opens a Socket Mode connection if configured.
359 // Set messaging:slack:enabled + messaging:slack:bot_token + messaging:slack:app_token
360 // in settings.json to activate.
361 {
362 let settings = config_watcher.get_settings();
363 if settings.messaging_slack_enabled {
364 match (
365 settings.messaging_slack_bot_token.clone(),
366 settings.messaging_slack_app_token.clone(),
367 ) {
368 (Some(bot_token), Some(app_token))
369 if !bot_token.is_empty() && !app_token.is_empty() =>
370 {
371 messaging::slack::SlackBridge::init_global(
372 messaging::slack::SlackConfig {
373 bot_token,
374 app_token,
375 channel_id: settings.messaging_slack_channel.clone(),
376 target_agent: settings.messaging_slack_target.clone(),
377 },
378 reqwest::Client::new(),
379 );
380 }
381 _ => {
382 tracing::warn!(
383 "slack bridge: enabled but messaging:slack:bot_token and/or \
384 messaging:slack:app_token is not set in settings.json"
385 );
386 }
387 }
388 }
389 }
390
391 // WhatsApp Cloud API messaging bridge — outbound send + inbound webhook
392 // receiver. Unlike Discord/Telegram/Slack there is no bridge-managed
393 // network connection to open at startup: inbound delivery is passive
394 // HTTP (the GET/POST /webhook/whatsapp routes, registered unauthenticated
395 // in server/mod.rs — Meta cannot supply X-AuthKey), reachable only once
396 // the operator's own tunnel is up and the callback URL is registered in
397 // Meta's App Dashboard, both manual one-time steps this process does not
398 // perform (v1 does not manage a tunnel subprocess — see
399 // messaging/whatsapp/mod.rs). Set messaging:whatsapp:enabled +
400 // access_token/app_secret/webhook_verify_token in settings.json to activate.
401 // See docs/specs/SPEC_MESSAGING_INTEGRATION_WHATSAPP_2026_07_07.md.
402 {
403 let settings = config_watcher.get_settings();
404 if settings.messaging_whatsapp_enabled {
405 match (
406 settings.messaging_whatsapp_access_token.clone(),
407 settings.messaging_whatsapp_app_secret.clone(),
408 settings.messaging_whatsapp_webhook_verify_token.clone(),
409 ) {
410 (Some(token), Some(secret), Some(verify_token))
411 if !token.is_empty() && !secret.is_empty() && !verify_token.is_empty() =>
412 {
413 messaging::whatsapp::WhatsAppBridge::init_global(
414 messaging::whatsapp::WhatsAppConfig {
415 phone_number_id: settings.messaging_whatsapp_phone_number_id.clone(),
416 access_token: token,
417 app_secret: secret,
418 webhook_verify_token: verify_token,
419 target_agent: settings.messaging_whatsapp_target.clone(),
420 fallback_template: settings.messaging_whatsapp_fallback_template.clone(),
421 fallback_template_lang: settings
422 .messaging_whatsapp_fallback_template_lang
423 .clone()
424 .unwrap_or_else(|| "en_US".to_string()),
425 },
426 reqwest::Client::new(),
427 );
428 if settings.messaging_whatsapp_tunnel_domain.is_empty() {
429 tracing::warn!(
430 "whatsapp bridge: enabled but messaging:whatsapp:tunnel_domain is not set — \
431 point your own tunnel at this instance's webhook port and register \
432 https://<your-domain>/webhook/whatsapp in Meta App Dashboard > WhatsApp > Configuration"
433 );
434 } else {
435 tracing::info!(
436 "whatsapp bridge: initialized — webhook callback = https://{}/webhook/whatsapp \
437 — verify this is registered in Meta App Dashboard > WhatsApp > Configuration \
438 (v1 does not manage the tunnel subprocess; ensure it's already running)",
439 settings.messaging_whatsapp_tunnel_domain
440 );
441 }
442 }
443 _ => {
444 tracing::warn!(
445 "whatsapp bridge: enabled but one of messaging:whatsapp:{{access_token,app_secret,webhook_verify_token}} is not set in settings.json"
446 );
447 }
448 }
449 }
450 }
451
452 // Set up docsite directory
453 if let Some(app_path) = base::get_mux_app_path() {
454 let docsite_dir = app_path.join("docsite");
455 docsite::set_docsite_dir(docsite_dir);
456 }
457
458 // Local MessageBus for inter-agent communication
459 let messagebus = Arc::new(backend::messagebus::MessageBus::new());
460
461 // Subagent watcher — monitors Claude Code session dirs for spawned subagents
462 let subagent_watcher = backend::subagent_watcher::SubagentWatcher::spawn(
463 event_bus.clone(),
464 mstore.clone(),
465 id_store.clone(),
466 identity_store.clone(),
467 );
468 // Wires `subagent:backfill_status` (scoped, persisted MPS event) — see
469 // `SubagentWatcher`'s `broker` field doc comment for why this is a
470 // post-construction setter rather than a constructor parameter.
471 subagent_watcher.set_broker(broker.clone());
472 // Registered as a global so blockcontroller/persistent/stdout_reader.rs's turn-end
473 // reconciliation hook (SPEC_SUBAGENT_LIVE_RECONCILIATION_AND_RETIRE_
474 // 2026_07_20 Phase A) can reach it without threading an Arc through
475 // PersistentSubprocessController::new and every one of its callers.
476 backend::subagent_watcher::set_global(subagent_watcher.clone());
477
478 // History service — discovers and indexes past CLI agent conversations
479 let history_service = Arc::new(backend::history::HistoryService::new());
480 history_service.warm_in_background();
481
482 // Install the pinned CLI of every provider the user's own agents use, in
483 // the background, so the first open after a pin bump doesn't wait on npm
484 // (SPEC_AGENT_OPEN_LATENCY_2026_09_27.md §4.1). Templates don't count:
485 // they span every provider. AGENTMUX_NO_CLI_WARM=1 turns it off.
486 if std::env::var("AGENTMUX_NO_CLI_WARM").map_or(true, |v| v != "1") {
487 match (
488 agentmux_common::DataPaths::from_env(),
489 mstore.agent_def_list(),
490 ) {
491 (Some(paths), Ok(defs)) => {
492 let providers: Vec<String> = defs
493 .into_iter()
494 .filter(|d| d.is_seeded != 1)
495 .map(|d| d.provider)
496 .collect();
497 backend::cli_install::warm_used_providers(paths, providers);
498 }
499 (_, Err(e)) => {
500 tracing::warn!(error = %e, "cli warm-up: cannot list agent definitions; skipped")
501 }
502 (None, _) => tracing::warn!("cli warm-up: no data paths; skipped"),
503 }
504 }
505
506 // Session archiver — auto-archive sessions inactive for >7 days, cap at 2 GB.
507 // Skip if home directory can't be determined (would otherwise fall back to a
508 // relative path and create archives under the current working directory).
509 if let Some(archive_dir) = backend::session_archive::default_archive_dir() {
510 let archiver = Arc::new(backend::session_archive::SessionArchiver::new(
511 mstore.clone(),
512 filestore.clone(),
513 7, // inactive days
514 2 * 1024 * 1024 * 1024, // 2 GB max
515 archive_dir,
516 ));
517 tokio::spawn(async move {
518 loop {
519 tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
520 match archiver.sweep().await {
521 Ok(stats) => tracing::info!(?stats, "session archiver sweep complete"),
522 Err(e) => tracing::warn!(error = %e, "session archiver sweep failed"),
523 }
524 }
525 });
526 } else {
527 tracing::warn!("session archiver: home dir unavailable, archiver disabled");
528 }
529
530 BackgroundSubsystems {
531 event_bus,
532 broker,
533 editor_file_watcher,
534 media_file_watcher,
535 files_watcher,
536 fs_watch_pool,
537 config_watcher,
538 reactive_handler,
539 poller,
540 messagebus,
541 subagent_watcher,
542 history_service,
543 }
544}
545
546
547/// Should `CloudSubscriber::init_global` be skipped this run?
548///
549/// Presence-based, matching the `AGENTMUX_DEV`/`AGENTMUX_TRAY` idiom
550/// elsewhere in this codebase. See the call site's doc comment for why
551/// skipping this matters: `init_global` triggers a real, synchronous
552/// OS-keychain read almost immediately, which macOS gates behind an
553/// interactive consent prompt for any process whose code signature isn't
554/// on the target credential's trusted-signer ACL — exactly the case for an
555/// ad-hoc/dev-signed `target/debug` binary spawned by an integration test.
556fn cloud_subscriber_disabled_from_env() -> bool {
557 std::env::var("AGENTMUX_DISABLE_CLOUD_SUBSCRIBER").is_ok()
558}
559
560#[cfg(test)]
561mod cloud_subscriber_gate_tests {
562 use super::*;
563 use std::sync::Mutex;
564
565 // Serialize: both tests mutate the same process-global env var, and
566 // `cargo test` runs tests in parallel by default within one binary.
567 static ENV_LOCK: Mutex<()> = Mutex::new(());
568
569 fn lock() -> std::sync::MutexGuard<'static, ()> {
570 ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
571 }
572
573 #[test]
574 fn defaults_to_enabled_so_shipped_and_dev_behavior_is_unchanged() {
575 let _guard = lock();
576 std::env::remove_var("AGENTMUX_DISABLE_CLOUD_SUBSCRIBER");
577 assert!(
578 !cloud_subscriber_disabled_from_env(),
579 "must default to false — the shipped app and `task dev` need the \
580 real muxbus reconnect-on-launch behavior with no opt-out set"
581 );
582 }
583
584 #[test]
585 fn is_disabled_when_the_var_is_present_regardless_of_value() {
586 let _guard = lock();
587 // Presence-based, not value-based — matches AGENTMUX_DEV/AGENTMUX_TRAY.
588 // An empty value must still disable it; a caller setting the var to
589 // "" to mean "off" would otherwise silently get the opposite of
590 // what every other flag in this idiom does.
591 std::env::set_var("AGENTMUX_DISABLE_CLOUD_SUBSCRIBER", "");
592 assert!(cloud_subscriber_disabled_from_env());
593 std::env::set_var("AGENTMUX_DISABLE_CLOUD_SUBSCRIBER", "1");
594 assert!(cloud_subscriber_disabled_from_env());
595 std::env::remove_var("AGENTMUX_DISABLE_CLOUD_SUBSCRIBER");
596 }
597}