Skip to main content

agentmux_launcher/
main.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4// AgentMux Launcher — Sets DLL search path then spawns srv + the CEF host.
5//
6// Phase B.1: launcher now spawns srv directly (sibling of host) so srv
7// survives host crashes. Both children are assigned to the launcher's
8// Job Object J0 with KILL_ON_JOB_CLOSE; killing the launcher reaps
9// the entire tree atomically via the OS.
10//
11// This was previously a tiny sync wrapper that just SetDllDirectoryW'd
12// runtime/ then spawned the CEF host. Phase B grew it into the
13// privileged owner per
14// docs/specs/SPEC_WINDOW_PROCESS_STATE_MACHINE_2026_04_27.md.
15//
16// Process tree after B.1:
17//   launcher (J0)
18//     ├── srv     (assigned to J0; survives host crash)
19//     └── host    (assigned to J0; CEF render workers inherit J0)
20
21#![cfg_attr(
22    all(not(debug_assertions), target_os = "windows"),
23    windows_subsystem = "windows"
24)]
25
26mod autostart;
27mod background_config;
28mod binary_resolution;
29mod data_dir;
30mod diag;
31mod event_log;
32mod hash;
33mod host_pipe;
34mod host_spawn;
35mod ipc;
36mod job_object;
37mod logging;
38mod mem_supervisor;
39// OS toast presenter (SPEC_OS_NOTIFICATIONS_SYSTEM_2026_09_24). Started by the
40// Windows and Linux supervisors; macOS has no backend yet (§6.2).
41#[cfg_attr(target_os = "macos", allow(dead_code))]
42mod notify;
43mod start_at_login;
44mod other_instances;
45mod reducer;
46mod saga;
47mod second_instance;
48mod supervisor;
49mod teardown_backstop;
50mod tray;
51mod ui_liveness;
52// Windows-only consumer (supervisor/windows.rs); pure parts are tested everywhere.
53#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
54mod host_hang_dump;
55mod upgrade;
56#[cfg(target_os = "windows")]
57mod splash;
58#[cfg(target_os = "macos")]
59mod splash_mac;
60#[cfg(target_os = "linux")]
61mod splash_linux;
62// Splash footer support. The baked font + software text blitter are only used by
63// the software-buffer backends (Linux, Windows); macOS renders native text.
64#[cfg(any(target_os = "linux", target_os = "windows"))]
65mod splash_font;
66#[cfg(any(target_os = "linux", target_os = "windows"))]
67mod splash_text;
68mod splash_config;
69mod splash_core;
70mod splash_info;
71mod startup_events;
72// srv hang-while-alive detection plugs into srv recycle-on-exit
73// (SRV_RESTART_BUDGET, supervisor/mod.rs), which is Windows-only today.
74#[cfg(target_os = "windows")]
75mod srv_liveness;
76mod srv_spawner;
77#[cfg(unix)]
78mod stale_srv;
79mod state;
80mod wrr;
81
82use binary_resolution::find_cef_binary;
83use logging::log;
84
85/// Suppress the Windows "Application Error" / WER crash dialog so an unhandled
86/// fault terminates the process immediately instead of wedging it behind a
87/// modal. No-op off Windows. Spec:
88/// docs/specs/SPEC_SERVICE_SUPERVISION_AND_RECOVERY_2026_05_20.md.
89#[cfg(target_os = "windows")]
90fn suppress_os_crash_dialogs() {
91    use windows_sys::Win32::System::Diagnostics::Debug::{SetErrorMode, SEM_FAILCRITICALERRORS};
92    use windows_sys::Win32::System::ErrorReporting::{WerSetFlags, WER_FAULT_REPORTING_NO_UI};
93    unsafe {
94        // Suppress the WER crash-dialog UI WITHOUT disabling WER itself —
95        // SEM_NOGPFAULTERRORBOX would also kill WER/LocalDumps crash-dump
96        // collection, the postmortem diagnostics this stability work needs.
97        // WER_FAULT_REPORTING_NO_UI is the documented "no UI, keep
98        // reports" path.
99        let _ = WerSetFlags(WER_FAULT_REPORTING_NO_UI);
100        // SEM_FAILCRITICALERRORS suppresses the critical-error handler
101        // (e.g. "no disk in drive" popups) — unrelated to crash reporting.
102        SetErrorMode(SEM_FAILCRITICALERRORS);
103    }
104}
105
106#[cfg(not(target_os = "windows"))]
107fn suppress_os_crash_dialogs() {}
108
109/// Process entry point. `suppress_os_crash_dialogs()` runs FIRST — before the
110/// Tokio runtime is built. The runtime is built explicitly here (rather than
111/// via `#[tokio::main]`, whose generated wrapper would construct it before any
112/// of our code runs) so a fault during runtime construction can't surface the
113/// Windows crash modal either. Spec:
114/// docs/specs/SPEC_SERVICE_SUPERVISION_AND_RECOVERY_2026_05_20.md.
115fn main() {
116    suppress_os_crash_dialogs();
117
118    // Dev/demo affordance: `--splash-selftest` shows the splash in isolation
119    // (no srv/host), holds it briefly, then exits — for eyeballing the footer +
120    // layout. See SPEC_SPLASH_USERINFO_AND_DISABLE_2026_06_21.md.
121    if std::env::args().any(|a| a == "--splash-selftest") {
122        splash_selftest();
123        return;
124    }
125
126    // Auto-start control verbs (issue #2977 WS2). Handled before any heavy
127    // startup work — they must not spawn srv/host or disturb a running
128    // instance. Gives an uninstaller a callable removal path, which is what
129    // Workstream 4 requires.
130    {
131        // Before anything reads it, and before any child could inherit it.
132        autostart::take_stable_exe_env();
133        let args: Vec<String> = std::env::args().collect();
134        if autostart::handle_cli(&args) {
135            return;
136        }
137        // Background-service mode + tray: resolve from env / `--background` /
138        // settings and export into OUR env, before any thread or child exists.
139        // Everything below (macOS headless pump, tray, host spawn) reads it.
140        // SPEC_OS_NOTIFICATIONS_SYSTEM_2026_09_24.md §4.1.
141        background_config::apply(&args);
142    }
143
144    // macOS: paint the splash FIRST, on the main thread, before any heavy work
145    // — this is the whole reason the splash lives in the small fast launcher
146    // rather than the slow CEF host. AppKit must own the main thread, so the
147    // srv+host supervisor (`launcher_main`) runs on a worker thread with its
148    // own Tokio runtime; the splash pumps a CoreFoundation runloop on main
149    // until the host signals first paint. See `splash_mac`.
150    #[cfg(target_os = "macos")]
151    {
152        if splash_config::splash_disabled() {
153            if tray::should_enable(tray::tray_opt_in_from_env(), tray::background_service_from_env()) {
154                // Splash disabled BUT the menu-bar item is on (the default, and
155                // always in background-service mode): the main thread must
156                // still pump AppKit, or there is no menu-bar item and no reopen
157                // delegate once every window is closed (design doc §7.5.1).
158                // Same thread layout as the splash path, minus the window.
159                splash_mac::prepare_headless_app();
160                spawn_supervisor_thread(None);
161                splash_mac::pump_forever();
162            }
163            // Splash disabled and no menu-bar item → no AppKit at all; run the
164            // supervisor directly on the main thread (there's no runloop to
165            // pump without a window or a tray).
166            tokio::runtime::Runtime::new()
167                .expect("failed to build Tokio runtime")
168                .block_on(launcher_main(None));
169            return;
170        }
171        // Create the startup-event sink/receiver here (before the splash
172        // window exists) and thread the sink into launcher_main on the
173        // worker thread, the receiver into the splash on the main thread —
174        // mirrors the Linux branch below. Closes the gap where macOS
175        // previously always passed None and the receiver got dropped
176        // unread in run_unix. See SPEC_MACOS_LAUNCH_SPEED_AND_SPLASH_
177        // TELEMETRY_2026_07_02.md §B.3-B.4.
178        let (startup_sink, startup_rx) = startup_events::StartupEventSink::new();
179        let splash = splash_mac::Splash::show(startup_rx);
180        spawn_supervisor_thread(Some(startup_sink));
181        splash.run_until_dismissed(); // pumps the runloop, then parks forever
182        return;
183    }
184
185    #[cfg(not(target_os = "macos"))]
186    {
187        // Linux: paint the splash before any heavy work (mirrors the macOS path,
188        // but on its own thread since neither X11 nor Wayland needs the main
189        // thread). spawn() sets AGENTMUX_SPLASH_READY_FILE so the host — spawned
190        // later inside launcher_main — inherits it and signals first paint.
191        // Windows keeps spawning its splash inside launcher_main (event-name
192        // model). See splash_linux/.
193        #[cfg(target_os = "linux")]
194        let linux_startup_sink = {
195            let (sink, rx) = startup_events::StartupEventSink::new();
196            if !splash_config::splash_disabled() {
197                splash_linux::spawn(rx);
198            } else {
199                drop(rx);
200            }
201            Some(sink)
202        };
203        #[cfg(not(target_os = "linux"))]
204        let linux_startup_sink: Option<startup_events::StartupEventSink> = None;
205
206        tokio::runtime::Runtime::new()
207            .expect("failed to build Tokio runtime")
208            .block_on(launcher_main(linux_startup_sink));
209    }
210}
211
212/// macOS: run the srv+host supervisor (`launcher_main`) on a worker thread with
213/// its own Tokio runtime, leaving the main thread to AppKit. The thread owns
214/// process lifetime: it `exit`s when the supervisor returns, which is what ends
215/// the main thread's pump loop.
216#[cfg(target_os = "macos")]
217fn spawn_supervisor_thread(startup_sink: Option<startup_events::StartupEventSink>) {
218    std::thread::Builder::new()
219        .name("launcher-supervisor".into())
220        .spawn(move || {
221            // Catch panics so a supervisor crash always exits the process
222            // rather than leaving the main-thread AppKit runloop spinning
223            // as an invisible orphan.
224            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
225                tokio::runtime::Runtime::new()
226                    .expect("failed to build Tokio runtime")
227                    .block_on(launcher_main(startup_sink));
228            }));
229            if result.is_err() {
230                eprintln!("AgentMux launcher supervisor panicked — exiting");
231                std::process::exit(1);
232            }
233            // Supervisor finished cleanly (host exited / fatal).
234            std::process::exit(0);
235        })
236        .expect("failed to spawn launcher supervisor thread");
237}
238
239/// `--splash-selftest`: show the splash with no srv/host behind it, hold it for a
240/// few seconds (or `AGENTMUX_SPLASH_HOLD_MS`), then exit. A demo/dev affordance
241/// for eyeballing the footer + centering without launching the whole app.
242fn splash_selftest() {
243    let hold = std::env::var("AGENTMUX_SPLASH_HOLD_MS")
244        .ok()
245        .and_then(|s| s.parse::<u64>().ok())
246        .map(|ms| std::time::Duration::from_millis(ms.max(3000)))
247        .unwrap_or_else(|| std::time::Duration::from_secs(6));
248
249    #[cfg(target_os = "linux")]
250    {
251        let (sink, rx) = startup_events::StartupEventSink::new();
252        // Fire fake startup events so the stage list is exercised.
253        std::thread::spawn(move || {
254            std::thread::sleep(std::time::Duration::from_millis(300));
255            sink.stage_begin("prep", "Launcher setup");
256            std::thread::sleep(std::time::Duration::from_millis(120));
257            sink.stage_end("prep", 120, startup_events::StartupStatus::Ok, None);
258            std::thread::sleep(std::time::Duration::from_millis(100));
259            sink.stage_begin("migrations", "Migrations");
260            sink.sub_begin("migrations", "0009", "cron_schema");
261            std::thread::sleep(std::time::Duration::from_millis(80));
262            sink.sub_end("migrations", "0009", 80, startup_events::StartupStatus::Ok, None);
263            sink.sub_begin("migrations", "0010", "identity_dedup");
264            std::thread::sleep(std::time::Duration::from_millis(40));
265            sink.sub_end("migrations", "0010", 40, startup_events::StartupStatus::Ok, None);
266            sink.stage_end("migrations", 220, startup_events::StartupStatus::Ok, None);
267            std::thread::sleep(std::time::Duration::from_millis(200));
268            sink.stage_begin("backend", "Backend startup");
269            std::thread::sleep(std::time::Duration::from_millis(1500));
270            sink.stage_end("backend", 1500, startup_events::StartupStatus::Ok, None);
271        });
272        splash_linux::spawn(rx);
273        std::thread::sleep(hold);
274    }
275    #[cfg(target_os = "macos")]
276    {
277        let (sink, rx) = startup_events::StartupEventSink::new();
278        // Fire fake startup events so the stage panel is exercised — same
279        // fixture shape as the Linux branch above.
280        std::thread::spawn(move || {
281            std::thread::sleep(std::time::Duration::from_millis(300));
282            sink.stage_begin("prep", "Launcher setup");
283            std::thread::sleep(std::time::Duration::from_millis(120));
284            sink.stage_end("prep", 120, startup_events::StartupStatus::Ok, None);
285            std::thread::sleep(std::time::Duration::from_millis(100));
286            sink.stage_begin("migrations", "Migrations");
287            sink.sub_begin("migrations", "0009", "cron_schema");
288            std::thread::sleep(std::time::Duration::from_millis(80));
289            sink.sub_end("migrations", "0009", 80, startup_events::StartupStatus::Ok, None);
290            sink.sub_begin("migrations", "0010", "identity_dedup");
291            std::thread::sleep(std::time::Duration::from_millis(40));
292            sink.sub_end("migrations", "0010", 40, startup_events::StartupStatus::Ok, None);
293            sink.stage_end("migrations", 220, startup_events::StartupStatus::Ok, None);
294            std::thread::sleep(std::time::Duration::from_millis(200));
295            sink.stage_begin("backend", "Backend startup");
296            std::thread::sleep(std::time::Duration::from_millis(1500));
297            sink.stage_end("backend", 1500, startup_events::StartupStatus::Ok, None);
298            std::thread::sleep(std::time::Duration::from_millis(100));
299            sink.stage_begin("host", "Host startup");
300            std::thread::sleep(std::time::Duration::from_millis(90));
301            sink.stage_end("host", 90, startup_events::StartupStatus::Ok, None);
302        });
303        let splash = splash_mac::Splash::show(rx);
304        if let Ok(p) = std::env::var("AGENTMUX_SPLASH_DUMP_PNG") {
305            splash.dump_png(&p);
306        }
307        let _ = splash; // run_until_dismissed parks; selftest just holds then exits
308        std::thread::sleep(hold);
309    }
310    #[cfg(target_os = "windows")]
311    {
312        let (_sink, rx) = startup_events::StartupEventSink::new();
313        let _ = splash::spawn_splash("selftest", rx, false);
314        std::thread::sleep(hold);
315    }
316}
317
318async fn launcher_main(startup_sink: Option<startup_events::StartupEventSink>) {
319    let exe_path = std::env::current_exe().expect("cannot resolve exe path");
320    let exe_dir = exe_path.parent().expect("exe has no parent directory");
321    // Production + Windows dev use a `runtime/` subdir (launcher at root,
322    // host + libs + srv under runtime/). The macOS/Linux `task dev` flat
323    // layout (Phase 1, SPEC_LAUNCHER_MACOS_DEV_INTEGRATION_2026_05_30)
324    // drops the launcher next to the host in dist/cef-dev/ so the host's
325    // `../Frameworks` resolution and asset anchoring are byte-identical to
326    // the legacy direct-invoke path — no `runtime/` to descend into. Fall
327    // back to exe_dir when there's no runtime/ subdir. Windows always has
328    // one, so its behavior is unchanged.
329    let runtime_dir = {
330        let rt = exe_dir.join("runtime");
331        if rt.is_dir() {
332            rt
333        } else {
334            exe_dir.to_path_buf()
335        }
336    };
337
338    log(&format!(
339        "starting — exe={} runtime={}",
340        exe_path.display(),
341        runtime_dir.display()
342    ));
343
344    // Set DLL search path so libcef.dll (in runtime/) is found by the
345    // CEF host's load-time linker. SetDllDirectoryW is process-local
346    // and inherited by child processes — both srv (which doesn't
347    // need libcef but harmless) and host (which absolutely does).
348    #[cfg(target_os = "windows")]
349    {
350        use std::os::windows::ffi::OsStrExt;
351        let wide: Vec<u16> = runtime_dir
352            .as_os_str()
353            .encode_wide()
354            .chain(Some(0))
355            .collect();
356        unsafe {
357            windows_sys::Win32::System::LibraryLoader::SetDllDirectoryW(wide.as_ptr());
358        }
359    }
360    log("SetDllDirectoryW done");
361
362    let args: Vec<String> = std::env::args().skip(1).collect();
363
364    // LSD-3 — `agentmux.exe --diag sagas` is OFFLINE: it reads the
365    // launcher saga SQLite log directly, with no IPC and no running
366    // launcher. So it MUST run BEFORE the CEF runtime existence
367    // check below — the offline-diagnostic value is most needed
368    // exactly when the launcher won't start (e.g. corrupt runtime
369    // folder). (codex P1 + reagent P1 PR #647 round 3.)
370    if matches!(
371        (args.first().map(String::as_str), args.get(1).map(String::as_str)),
372        (Some("--diag"), Some("sagas"))
373    ) {
374        match diag::run_sagas_diag(exe_dir).await {
375            Ok(()) => std::process::exit(0),
376            Err(msg) => {
377                eprintln!("--diag sagas failed: {}", msg);
378                std::process::exit(1);
379            }
380        }
381    }
382
383    let real_exe = find_cef_binary(&runtime_dir);
384    log(&format!("resolved CEF binary: {}", real_exe.display()));
385    // Self-spawn guard: if host resolution ever points back at the
386    // launcher's own binary (the flat dev layout's failure mode —
387    // launcher + host co-located), spawning it would recurse into an
388    // unbounded launcher fork bomb. find_cef_binary excludes
389    // `agentmux-launcher` by name; this is the loud backstop in case a
390    // future binary slips past that filter. Compare canonicalized paths
391    // so symlink/`./` differences don't defeat the check.
392    if let (Ok(a), Ok(b)) = (
393        std::fs::canonicalize(&real_exe),
394        std::fs::canonicalize(&exe_path),
395    ) {
396        if a == b {
397            log(&format!(
398                "FATAL: host resolved to the launcher's own binary ({}) — refusing to self-spawn",
399                a.display()
400            ));
401            eprintln!("AgentMux runtime is misconfigured (host == launcher). Aborting.");
402            std::process::exit(1);
403        }
404    }
405    if !real_exe.exists() {
406        log(&format!(
407            "FATAL: CEF binary not found at {}",
408            real_exe.display()
409        ));
410        eprintln!(
411            "AgentMux runtime not found in: {}\nMake sure the runtime/ folder is intact.",
412            runtime_dir.display()
413        );
414        std::process::exit(1);
415    }
416
417    log(&format!("forwarding {} CLI args to host", args.len()));
418
419    // Phase B.8 — `agentmux.exe --diag wrr` and `--diag srv` Tool
420    // clients. Connect to the running launcher (or srv) over IPC,
421    // capture events for a short window, print summary, exit.
422    // (Note: --diag sagas is handled above, before the CEF runtime
423    // check, since it doesn't need IPC.)
424    if matches!(args.first().map(String::as_str), Some("--diag")) {
425        let topic = args.get(1).map(String::as_str).unwrap_or("");
426        match topic {
427            "wrr" => match diag::run_wrr_diag(exe_dir).await {
428                Ok(()) => std::process::exit(0),
429                Err(msg) => {
430                    eprintln!("--diag wrr failed: {}", msg);
431                    std::process::exit(1);
432                }
433            },
434            // Phase E.7 — operator visibility into the srv reducer's
435            // canonical state (workspaces / tabs / blocks / sagas) +
436            // recent activity. Same `Tool` IPC pattern as `--diag wrr`,
437            // talks to the srv pipe instead of the launcher pipe.
438            "srv" => match diag::run_srv_diag(exe_dir).await {
439                Ok(()) => std::process::exit(0),
440                Err(msg) => {
441                    eprintln!("--diag srv failed: {}", msg);
442                    std::process::exit(1);
443                }
444            },
445            // sagas is handled above, before the runtime check.
446            "sagas" => {
447                // Should never reach here — `sagas` is matched + handled
448                // above the CEF runtime check. Kept for completeness.
449                unreachable!("--diag sagas is handled before runtime check");
450            }
451            "" => {
452                eprintln!("usage: agentmux.exe --diag <topic>\nknown topics: wrr, srv, sagas");
453                std::process::exit(2);
454            }
455            other => {
456                eprintln!("unknown --diag topic: {} (known: wrr, srv, sagas)", other);
457                std::process::exit(2);
458            }
459        }
460    }
461
462    #[cfg(target_os = "windows")]
463    {
464        let _ = startup_sink;
465        supervisor::run_windows(exe_dir, &real_exe, &args).await;
466    }
467
468    #[cfg(not(target_os = "windows"))]
469    {
470        // Phase 1 (SPEC_LAUNCHER_MACOS_DEV_INTEGRATION_2026_05_30):
471        // the launcher now owns srv + host on macOS/Linux too — it
472        // spawns the backend, hands the host its endpoints via env,
473        // and supervises both with the same crash budget Windows uses.
474        // The legacy exec-into-host escape hatch lives in
475        // `task dev:standalone` (host invoked directly, no launcher).
476        supervisor::run_unix(exe_dir, &real_exe, &args, startup_sink).await;
477    }
478}
479
480/// Show a modal error dialog before the launcher exits. Used for
481/// genuine bind failures (NOT the "already running" path — that
482/// silently forwards via `forward_open_new_window`). Without this,
483/// the launcher exit is silent (it has the `windows` subsystem in
484/// release, so eprintln! goes nowhere).
485#[cfg(target_os = "windows")]
486pub(crate) fn show_fatal_dialog(title: &str, body: &str) {
487    use std::os::windows::ffi::OsStrExt;
488    use windows_sys::Win32::UI::WindowsAndMessaging::{
489        MessageBoxW, MB_ICONWARNING, MB_OK, MB_SETFOREGROUND, MB_TOPMOST,
490    };
491    let title_w: Vec<u16> = std::ffi::OsStr::new(title)
492        .encode_wide()
493        .chain(Some(0))
494        .collect();
495    let body_w: Vec<u16> = std::ffi::OsStr::new(body)
496        .encode_wide()
497        .chain(Some(0))
498        .collect();
499    unsafe {
500        MessageBoxW(
501            std::ptr::null_mut(),
502            body_w.as_ptr(),
503            title_w.as_ptr(),
504            MB_OK | MB_ICONWARNING | MB_SETFOREGROUND | MB_TOPMOST,
505        );
506    }
507}
508
509#[cfg(not(target_os = "windows"))]
510pub(crate) fn show_fatal_dialog(_title: &str, body: &str) {
511    eprintln!("{}", body);
512}
513