Skip to main content

agentmux_launcher/
srv_spawner.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Spawn the agentmux-srv backend sidecar from the LAUNCHER (Phase B.1).
5//
6// Today's flow (pre-Phase-B): launcher spawns host; host spawns srv;
7// host owns a Job Object J1 wrapping srv; renderers inherit launcher's
8// J0 via host. Result: when host crashes, J1 closes and srv dies.
9//
10// Phase B.1 flow: launcher spawns BOTH srv and host as siblings,
11// assigns BOTH to launcher's J0 directly. Host's J1 on srv is
12// deleted (it would actively defeat "srv survives host crash" if
13// kept). Renderers continue to inherit J0 via host as before.
14//
15// The launcher passes srv's endpoints to the host via env vars
16// (AGENTMUX_BACKEND_WS, _WEB, _PID). Host detects them and skips
17// its own spawn_backend path (which is preserved for `task dev`
18// fallback where launcher isn't in the loop).
19//
20// Adapted from `crates/cef/src/sidecar.rs::spawn_backend` —
21// kept structurally similar so divergence is auditable. Key
22// differences:
23//   * Tokio process API (not std::process)
24//   * CREATE_SUSPENDED + assign-to-job + ResumeThread (PR #570 race
25//     pattern, applied to srv too)
26//   * No separate Job Object on srv; launcher's J0 covers it
27//   * Auth key generated here, not consumed from a shared AppState
28//   * stderr ESTART parsing returns the result via tokio mpsc
29
30#[cfg(windows)]
31use agentmux_common::win32::NoWindow;
32use std::path::{Path, PathBuf};
33use std::process::Stdio;
34
35use tokio::io::{AsyncBufReadExt, BufReader};
36use tokio::process::{Child, Command};
37use tokio::sync::mpsc;
38
39use crate::data_dir::DataPaths;
40
41/// What the launcher learns about srv after it signals ready.
42/// Held by the launcher and used to populate env vars the host reads.
43#[derive(Debug, Clone)]
44pub struct SrvSpawnResult {
45    pub pid: u32,
46    pub ws_endpoint: String,
47    pub web_endpoint: String,
48    pub instance_id: String,
49    pub auth_key: String,
50    /// Shared secret proving to srv that a `host_ipc.Register` call really
51    /// comes from the paired host, not an agent process (agents share
52    /// `auth_key` too — see `crates/srv/src/server/service/host_ipc.rs`).
53    /// Minted once per `spawn_srv` call (same lifetime as `auth_key`) and
54    /// given to both host and srv's spawn env — see `host_spawn.rs`'s
55    /// `AGENTMUX_HOST_REG_SECRET` env line and this file's own
56    /// `AGENTMUX_HOST_REG_SECRET` env line below. Survives every host-only
57    /// crash-restart automatically, exactly like `auth_key` does (this
58    /// struct is held in the launcher's own stack frame across restarts —
59    /// see `SrvSpawnResult`'s own doc comment above).
60    pub host_reg_secret: String,
61    /// Number of data migrations still pending after the in-process startup run.
62    /// Counted by srv at ESTART time (`count_pending_migrations`). Since
63    /// SPEC_MIGRATION_SYSTEM_HARDENING Phase 1 a migration *failure* never
64    /// reaches ESTART at all (srv exits 1 with `MigrationFailed`), so a
65    /// non-zero value here means the count and the run disagree — e.g. the
66    /// fresh-install ordering case runner.rs documents — and the status-bar
67    /// shows its retry message for that.
68    pub pending_migrations: usize,
69    /// RFC3339 timestamp captured when ESTART arrived. Carried on the
70    /// result for `--diag` / debug observability; not currently
71    /// propagated into env. F.7 cleanup audit: keep with allow + this
72    /// note rather than delete — a future `--diag srv` printer is the
73    /// natural reader.
74    #[allow(dead_code)]
75    pub started_at: String,
76}
77
78/// Errors during srv spawn — granular enough that the launcher can
79/// log the right diagnostic.
80#[derive(Debug)]
81pub enum SrvSpawnError {
82    BinaryNotFound(String),
83    SpawnFailed(String),
84    #[cfg(target_os = "windows")]
85    JobAssignFailed(String),
86    #[cfg(target_os = "windows")]
87    ResumeFailed(String),
88    EstartTimeout,
89    EstartChannelClosed,
90    /// srv reported `AGENTMUXSRV-MIGRATION-FAILED` and exited 1 before ESTART
91    /// (SPEC_MIGRATION_SYSTEM_HARDENING_2026_08_03 Phase 1). Carries the
92    /// reason srv gave. Distinct from `EstartTimeout` so the supervisor can
93    /// show the user a real message instead of "timeout (30s)" — and so it
94    /// can do it immediately rather than 30s later.
95    MigrationFailed(String),
96}
97
98impl std::fmt::Display for SrvSpawnError {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        match self {
101            Self::BinaryNotFound(s) => write!(f, "srv binary not found: {}", s),
102            Self::SpawnFailed(s) => write!(f, "spawn failed: {}", s),
103            #[cfg(target_os = "windows")]
104            Self::JobAssignFailed(s) => write!(f, "AssignProcessToJobObject failed: {}", s),
105            #[cfg(target_os = "windows")]
106            Self::ResumeFailed(s) => write!(f, "ResumeThread failed: {}", s),
107            Self::EstartTimeout => write!(f, "timeout waiting for AGENTMUXSRV-ESTART (30s)"),
108            Self::EstartChannelClosed => {
109                write!(f, "ESTART channel closed before srv signalled ready")
110            }
111            Self::MigrationFailed(s) => write!(f, "database migration failed: {}", s),
112        }
113    }
114}
115
116/// Title for the fatal dialog both supervisors show on `MigrationFailed`.
117pub const MIGRATION_FAILED_DIALOG_TITLE: &str = "AgentMux — database migration failed";
118
119/// Body for that dialog. Kept here (not in each supervisor) so Windows and
120/// Unix show the same words.
121///
122/// Every sentence is something the launcher actually knows:
123/// - Restart-to-retry: srv re-runs every unapplied migration on the next
124///   launch (`run_pending_migrations` filters on `migration_is_applied`).
125/// - The backup ordering: `runner.rs::run_pending_migrations` calls
126///   `backup_stores` (→ `~/.agentmux/shared/backups/pre-migration-<ver>-<ts>/`)
127///   BEFORE applying anything, and returns `Err("... backup failed: ...")`
128///   without running a migration if that copy fails. So the reason text
129///   itself tells the user which case they are in; we state the ordering
130///   and let it do so, rather than asserting a backup exists.
131///
132/// We deliberately do NOT claim "your data has not been changed" or "a
133/// snapshot was taken": the backup step is one of the things that can be
134/// the failure, and bootstrap/stores.rs's separate
135/// `maybe_snapshot_pre_migration` is best-effort (non-fatal on error,
136/// `Ok(None)` when it decides none is needed). An earlier revision of this
137/// text made that claim unconditionally (#3043).
138pub fn migration_failed_dialog_body(reason: &str) -> String {
139    format!(
140        "AgentMux could not update its data store and will not start.\n\n\
141         {}\n\n\
142         Restart AgentMux to retry. Migrations only run after a copy of the \
143         store is written to ~/.agentmux/shared/backups/pre-migration-*/ — \
144         if the error above is about writing that copy, nothing was changed. \
145         Run `muxlog srv` for the full log.",
146        reason
147    )
148}
149
150/// Outcome of waiting for srv's readiness signal. Module-level (not local to
151/// `spawn_srv`) so `prefer_buffered_failure` can be unit-tested.
152enum EstartWait {
153    Ready(Option<SrvSpawnResult>),
154    Timeout,
155    MigrationFailed(String),
156}
157
158/// Second guard for the ESTART-vs-failure race (reagent P1, post-merge on
159/// #3043). The stderr reader task sends the migration-failure reason and then
160/// drops every sender as srv exits, so by the time the waiter's `select!`
161/// runs, BOTH "failure buffered" and "ESTART channel closed" are ready.
162/// `biased;` in the select makes the failure arm win when both are seen in
163/// one poll; this covers the remaining shape, where the closed channel was
164/// observed first and the reason is sitting in `failure_rx` unread. Every
165/// other outcome passes through unchanged.
166fn prefer_buffered_failure(recv: EstartWait, failure_rx: &mut mpsc::Receiver<String>) -> EstartWait {
167    match recv {
168        EstartWait::Ready(None) => match failure_rx.try_recv() {
169            Ok(reason) => EstartWait::MigrationFailed(reason),
170            Err(_) => EstartWait::Ready(None),
171        },
172        other => other,
173    }
174}
175
176#[cfg(test)]
177mod estart_race_tests {
178    use super::*;
179
180    #[test]
181    fn closed_estart_channel_with_buffered_failure_becomes_migration_failed() {
182        let (tx, mut rx) = mpsc::channel::<String>(1);
183        // Reader task's exact sequence: send the reason, then drop the sender.
184        tx.try_send("migration 0007 failed: disk I/O error".into()).unwrap();
185        drop(tx);
186        match prefer_buffered_failure(EstartWait::Ready(None), &mut rx) {
187            EstartWait::MigrationFailed(r) => assert_eq!(r, "migration 0007 failed: disk I/O error"),
188            _ => panic!("buffered failure reason must win over a closed ESTART channel"),
189        }
190    }
191
192    #[test]
193    fn closed_estart_channel_without_failure_stays_channel_closed() {
194        let (tx, mut rx) = mpsc::channel::<String>(1);
195        drop(tx); // srv died before ESTART for some other reason — no failure line
196        assert!(matches!(
197            prefer_buffered_failure(EstartWait::Ready(None), &mut rx),
198            EstartWait::Ready(None)
199        ));
200    }
201
202    #[test]
203    fn other_outcomes_pass_through_untouched() {
204        let (tx, mut rx) = mpsc::channel::<String>(1);
205        tx.try_send("should not be consumed".into()).unwrap();
206        assert!(matches!(prefer_buffered_failure(EstartWait::Timeout, &mut rx), EstartWait::Timeout));
207        assert!(matches!(
208            prefer_buffered_failure(EstartWait::MigrationFailed("x".into()), &mut rx),
209            EstartWait::MigrationFailed(_)
210        ));
211        // Neither pass-through drained the channel.
212        assert_eq!(rx.try_recv().ok().as_deref(), Some("should not be consumed"));
213    }
214}
215
216/// Run `agentmux-srv migrate` as a standalone subprocess.
217///
218/// The migration runner exits 0 (success / nothing to do) or 1 (failure).
219/// On failure the caller should surface an error and not bring srv back up.
220/// stdout lines are newline-delimited JSON progress events — consumed inline
221/// so sub-events are delivered to the splash before `stage_end` is sent.
222///
223/// NOT called during ordinary startup — migrations run in-process inside srv
224/// via `run_pending_migrations` before ESTART is emitted for the normal
225/// zero-pending boot. This is the explicit-upgrade path instead: its real
226/// caller is `upgrade::run_migration_upgrade`, part of the fast-startup
227/// redesign (`docs/specs/SPEC_FAST_STARTUP_UPGRADE_OWNS_MIGRATIONS_AND_UPDATES_2026_09_15.md`,
228/// issue #3258) — was dead code with no callers before that.
229pub async fn run_migrate(
230    launcher_exe_dir: &Path,
231    paths: &DataPaths,
232    sink: &crate::startup_events::StartupEventSink,
233) -> Result<(), SrvSpawnError> {
234    sink.stage_begin("migrations", "Migrations");
235    let t = std::time::Instant::now();
236
237    let backend_path = resolve_srv_binary(launcher_exe_dir)?;
238
239    let mut cmd = tokio::process::Command::new(&backend_path);
240    cmd.args([
241        "--wavedata",
242        &paths.data_dir.to_string_lossy(),
243        "migrate",
244    ])
245    .envs(paths.common.to_env_vars())
246    // Auth key is not needed for migrate but the binary may check for it
247    // before dispatching. Provide a placeholder so argument parsing passes.
248    .env("AGENTMUX_AUTH_KEY", "migrate-placeholder")
249    .stdout(std::process::Stdio::piped())
250    .stderr(std::process::Stdio::piped())
251    .kill_on_drop(true);
252
253    #[cfg(target_os = "windows")]
254    cmd.no_window(); // CREATE_NO_WINDOW
255
256    let mut child = cmd
257        .spawn()
258        .map_err(|e| SrvSpawnError::SpawnFailed(format!("migrate spawn: {}", e)))?;
259
260    // Stderr to log only (non-blocking task).
261    if let Some(stderr) = child.stderr.take() {
262        tokio::spawn(async move {
263            let mut reader = tokio::io::BufReader::new(stderr).lines();
264            while let Ok(Some(line)) = reader.next_line().await {
265                crate::log(&format!("[migrate stderr] {}", line));
266            }
267        });
268    }
269
270    // Read stdout inline so sub-events arrive before stage_end is sent.
271    // We break as soon as we see the "complete" event — the process may hang
272    // in Tokio shutdown (crash-monitor task) after flushing its last line,
273    // so waiting for stdout EOF would block forever.
274    let mut applied = 0u32;
275    let mut skipped = 0u32;
276    let mut migration_complete = false;
277    if let Some(stdout) = child.stdout.take() {
278        let mut reader = tokio::io::BufReader::new(stdout).lines();
279        while let Ok(Some(line)) = reader.next_line().await {
280            crate::log(&format!("[migrate] {}", line));
281            match parse_migration_line(&line) {
282                Some(MigrationLine::Start { id, label }) => {
283                    sink.sub_begin("migrations", id, label);
284                }
285                Some(MigrationLine::Done { id, duration_ms }) => {
286                    sink.sub_end(
287                        "migrations",
288                        id,
289                        duration_ms,
290                        crate::startup_events::StartupStatus::Ok,
291                        None,
292                    );
293                }
294                Some(MigrationLine::Complete { applied: a, skipped: s }) => {
295                    applied = a;
296                    skipped = s;
297                    migration_complete = true;
298                    break; // Don't wait for EOF — kill below.
299                }
300                None => {}
301            }
302        }
303    }
304
305    // If the process signalled complete via stdout we know it succeeded.
306    // Kill it now so we don't hang on wait() if its Tokio runtime shutdown
307    // stalls (crash-monitor interaction, background tasks with long timeouts).
308    if migration_complete {
309        let _ = child.start_kill();
310    }
311
312    let status = child
313        .wait()
314        .await
315        .map_err(|e| SrvSpawnError::SpawnFailed(format!("migrate wait: {}", e)))?;
316
317    let duration_ms = t.elapsed().as_millis() as u64;
318    // migration_complete means the process emitted {"event":"complete"} on
319    // stdout before we killed it — that is the authoritative success signal.
320    // status.success() may be false when we force-killed the process after
321    // seeing complete (crash-monitor Tokio shutdown hung).
322    if migration_complete || status.success() {
323        let detail = if applied > 0 {
324            Some(format!("{} applied, {} current", applied, skipped))
325        } else if skipped > 0 {
326            Some(format!("all {} current", skipped))
327        } else {
328            None
329        };
330        sink.stage_end("migrations", duration_ms, crate::startup_events::StartupStatus::Ok, detail);
331        Ok(())
332    } else {
333        sink.stage_end(
334            "migrations",
335            duration_ms,
336            crate::startup_events::StartupStatus::Error,
337            Some("failed; see migration-error.log".into()),
338        );
339        Err(SrvSpawnError::SpawnFailed(format!(
340            "agentmux-srv migrate exited with status {}; see ~/.agentmux/logs/migration-error.log",
341            status.code().unwrap_or(-1)
342        )))
343    }
344}
345
346// ── Migration JSON parser ────────────────────────────────────────────────────
347
348enum MigrationLine {
349    Start { id: String, label: String },
350    Done { id: String, duration_ms: u64 },
351    Complete { applied: u32, skipped: u32 },
352}
353
354fn parse_migration_line(s: &str) -> Option<MigrationLine> {
355    let event = json_str(s, "event")?;
356    match event.as_str() {
357        "migration_start" => Some(MigrationLine::Start {
358            id: json_str(s, "id")?,
359            label: json_str(s, "description")
360                .unwrap_or_else(|| json_str(s, "id").unwrap_or_default()),
361        }),
362        "migration_done" => Some(MigrationLine::Done {
363            id: json_str(s, "id")?,
364            duration_ms: json_u64(s, "duration_ms").unwrap_or(0),
365        }),
366        "complete" => Some(MigrationLine::Complete {
367            applied: json_u64(s, "applied").unwrap_or(0) as u32,
368            skipped: json_u64(s, "skipped").unwrap_or(0) as u32,
369        }),
370        _ => None,
371    }
372}
373
374fn json_str(s: &str, key: &str) -> Option<String> {
375    let prefix = format!("\"{}\":\"", key);
376    let start = s.find(&prefix)? + prefix.len();
377    let rest = &s[start..];
378    let end = rest.find('"')?;
379    Some(rest[..end].to_string())
380}
381
382fn json_u64(s: &str, key: &str) -> Option<u64> {
383    let prefix = format!("\"{}\":", key);
384    let start = s.find(&prefix)? + prefix.len();
385    let rest = s[start..].trim_start();
386    let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len());
387    if end == 0 { return None; }
388    rest[..end].parse().ok()
389}
390
391/// Spawn srv as a child of the launcher, assigned to launcher's
392/// Job Object J0 so it dies cleanly with the launcher tree.
393///
394/// `launcher_exe_dir` is used to locate the srv binary alongside the
395/// launcher (or in `runtime/` for portable). `paths` carries the
396/// data + config dirs and is propagated to srv via env vars.
397/// `job_handle` is the launcher's Job Object so srv joins the same
398/// kill-on-job-close contract as the host. Returns once srv prints
399/// `AGENTMUXSRV-ESTART` (or the 30s timeout fires).
400///
401/// Caller keeps the returned `Child` alive — drop closes srv's
402/// stdin and srv's existing PPID death-watcher takes over (already
403/// part of agentmux-srv per `SPEC_BACKEND_LIFECYCLE.md`).
404// Phase E.1b — `srv_pipe_path` is the launcher-computed pipe path
405// (same data-dir hash as launcher's own pipe, different leaf name).
406// Passed via `AGENTMUX_SRV_PIPE_PATH` so srv doesn't have to
407// recompute the hash; launcher is the single source of truth.
408pub async fn spawn_srv(
409    launcher_exe_dir: &Path,
410    paths: &DataPaths,
411    srv_pipe_path: &str,
412    #[cfg(target_os = "windows")] job_handle: windows_sys::Win32::Foundation::HANDLE,
413    sink: &crate::startup_events::StartupEventSink,
414) -> Result<(SrvSpawnResult, Child), SrvSpawnError> {
415    sink.stage_begin("backend", "Backend startup");
416    let srv_t = std::time::Instant::now();
417    let backend_path = resolve_srv_binary(launcher_exe_dir)?;
418
419    // Generate a fresh auth_key per run (UUID v4 — same as host did).
420    // This is the launcher's responsibility now; host receives it via
421    // AGENTMUX_AUTH_KEY env so srv + host + frontend agree on the key.
422    let auth_key = uuid::Uuid::new_v4().to_string();
423    // Minted alongside auth_key, same lifetime/reuse rules — see
424    // `SrvSpawnResult::host_reg_secret`'s doc comment.
425    let host_reg_secret = uuid::Uuid::new_v4().to_string();
426    let version = env!("CARGO_PKG_VERSION");
427    let instance_id = format!("v{}", version);
428
429    // app_path = launcher's exe dir (used by srv for finding bundled
430    // tooling like jq.exe / rg.exe). In portable mode this is the
431    // top of the portable folder; the runtime/tools/bin/ subdir
432    // lives under runtime/, but srv's app_path lookup is currently
433    // exe_dir-based per the host's code.
434    //
435    // For B.1 we keep parity: pass exe_dir of the LAUNCHER. If srv
436    // tooling lookup breaks, follow up — log it loudly.
437    let app_path_str = launcher_exe_dir.to_string_lossy().to_string();
438
439    let mut cmd = Command::new(&backend_path);
440    cmd.args([
441        "--wavedata",
442        &paths.data_dir.to_string_lossy(),
443        "--instance",
444        &instance_id,
445    ])
446    .env("AGENTMUX_AUTH_KEY", &auth_key)
447    .env("AGENTMUX_HOST_REG_SECRET", &host_reg_secret)
448    // Canonical AGENTMUX_* env vars (INSTANCE_DIR / DATA_DIR /
449    // CONFIG_DIR / LOG_DIR / CEF_CACHE_DIR / AGENTS_DIR / INSTANCE_
450    // RUNTIME_DIR / SHARED_DIR / RUNTIME_MODE). Replaces the old
451    // AGENTMUX_DATA_HOME / AGENTMUX_DEV / AGENTMUX_CONFIG_HOME /
452    // AGENTMUX_SETTINGS_DIR pre-unification names. srv reads them
453    // via `DataPaths::from_env()` (or the raw var names directly).
454    .envs(paths.common.to_env_vars())
455    .env("AGENTMUX_APP_PATH", &app_path_str)
456    .env("AGENTMUX_SRV_PIPE_PATH", srv_pipe_path)
457    .stdin(Stdio::piped())
458    .stdout(Stdio::piped())
459    .stderr(Stdio::piped())
460    .kill_on_drop(false); // Job Object handles cleanup; tokio's kill-on-drop would force-kill.
461
462    // Windows: spawn suspended so we can assign-to-job before any
463    // child code runs (PR #570 race pattern, now applied to srv).
464    // Without this, srv could open files / sockets before joining
465    // the job — those resources would survive launcher death.
466    #[cfg(target_os = "windows")]
467    {
468        use agentmux_common::win32::CREATE_SUSPENDED;
469        use agentmux_common::win32::CREATE_NO_WINDOW;
470        cmd.creation_flags(CREATE_SUSPENDED | CREATE_NO_WINDOW);
471    }
472
473    // Linux process-tree reap (A0): PR_SET_PDEATHSIG → SIGKILL on
474    // launcher death so srv is reaped even if the launcher exits
475    // abnormally (panic/OOM/external SIGKILL). Linux analogue of the
476    // Windows Job Object cleanup the launcher already does. Same
477    // safety contract as the host pre_exec — async-signal-safe call
478    // only. macOS lacks prctl; the macOS launcher reap path is
479    // handled by tokio's child supervision in `run_unix`.
480    #[cfg(target_os = "linux")]
481    {
482        unsafe {
483            cmd.pre_exec(|| {
484                libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL, 0, 0, 0);
485                Ok(())
486            });
487        }
488    }
489
490    // SPEC_LAUNCHER_TEARDOWN_BACKSTOP_2026_07_11 unix parity — srv becomes
491    // its own process-group leader too, same rationale as
492    // `spawn_host_unix`'s `.process_group(0)`: the teardown backstop's
493    // `kill_process_group_forcefully` needs an explicit, launcher-scoped
494    // group for srv independent of host's (they spawn concurrently via
495    // `tokio::join!` in `run_unix`, so srv can't join a group host hasn't
496    // been assigned yet — each gets its own rather than coupling the two
497    // spawn call sites).
498    #[cfg(not(target_os = "windows"))]
499    cmd.process_group(0);
500
501    let mut child = cmd
502        .spawn()
503        .map_err(|e| SrvSpawnError::SpawnFailed(e.to_string()))?;
504    let pid = child
505        .id()
506        .ok_or_else(|| SrvSpawnError::SpawnFailed("child has no PID".to_string()))?;
507
508    // Windows: assign srv to launcher's job, then resume.
509    // Skip job assignment if launcher's job creation failed (J0 is
510    // null) — that's the degraded mode logged by main.rs. Srv still
511    // runs but won't be reaped on launcher death.
512    //
513    // Both error paths must explicitly start_kill the suspended
514    // child before returning. We set kill_on_drop(false) (J0 normally
515    // handles cleanup), so dropping the Child wouldn't terminate the
516    // suspended srv — it would orphan as a permanent zombie holding
517    // resources and the data dir lockfile, blocking subsequent
518    // launches (#571).
519    #[cfg(target_os = "windows")]
520    {
521        if !job_handle.is_null() {
522            if let Err(e) = assign_pid_to_job(pid, job_handle) {
523                let _ = child.start_kill();
524                return Err(SrvSpawnError::JobAssignFailed(e));
525            }
526        }
527        if let Err(e) = agentmux_common::win32::resume_main_thread(pid) {
528            let _ = child.start_kill();
529            return Err(SrvSpawnError::ResumeFailed(e));
530        }
531    }
532
533    let started_at = chrono::Utc::now().to_rfc3339();
534
535    // Forward srv stdout to our log (info level; the launcher's log
536    // file is the same one srv-logs end up in once we wire log
537    // forwarding properly).
538    if let Some(stdout) = child.stdout.take() {
539        let pid_for_log = pid;
540        tokio::spawn(async move {
541            let mut reader = BufReader::new(stdout).lines();
542            while let Ok(Some(line)) = reader.next_line().await {
543                crate::log(&format!("[srv {} stdout] {}", pid_for_log, line));
544            }
545        });
546    }
547
548    // Parse stderr for AGENTMUXSRV-ESTART (the readiness signal). srv
549    // writes other diagnostic lines too (AGENTMUXSRV-EVENT:..., plain
550    // text); for B.1 we just log them. Phase B sub-PR B.2 will
551    // forward AGENTMUXSRV-EVENT messages to subscribers via the IPC
552    // event stream.
553    let stderr = child
554        .stderr
555        .take()
556        .ok_or_else(|| SrvSpawnError::SpawnFailed("no stderr handle".to_string()))?;
557    let (tx, mut rx) = mpsc::channel::<SrvSpawnResult>(1);
558    // Separate channel so the stderr reader can signal that migrations are running.
559    // The ESTART waiter extends its deadline when it receives this ping so that a
560    // large-dataset migration that takes longer than the normal 30s boot window
561    // doesn't cause the launcher to kill srv prematurely.
562    let (migration_tx, mut migration_rx) = mpsc::channel::<()>(4);
563    // Third channel: srv reports a fatal migration failure on stderr and exits
564    // 1 without ESTART. Without this the waiter would only notice via the 30s
565    // timeout, and the user would see "timeout" instead of the actual error.
566    let (failure_tx, mut failure_rx) = mpsc::channel::<String>(1);
567    let auth_key_for_estart = auth_key.clone();
568    let host_reg_secret_for_estart = host_reg_secret.clone();
569    let started_at_for_estart = started_at.clone();
570    let pid_for_log = pid;
571    let sink_for_migrations = sink.clone();
572    tokio::spawn(async move {
573        let mut reader = BufReader::new(stderr).lines();
574        let mut estart_sent = false;
575        while let Ok(Some(line)) = reader.next_line().await {
576            if !estart_sent && line.starts_with("AGENTMUXSRV-ESTART") {
577                let parsed = parse_estart(&line);
578                let result = SrvSpawnResult {
579                    pid: pid_for_log,
580                    ws_endpoint: parsed.ws_endpoint,
581                    web_endpoint: parsed.web_endpoint,
582                    instance_id: parsed.instance_id,
583                    auth_key: auth_key_for_estart.clone(),
584                    host_reg_secret: host_reg_secret_for_estart.clone(),
585                    pending_migrations: parsed.pending_migrations,
586                    started_at: started_at_for_estart.clone(),
587                };
588                crate::log(&format!(
589                    "srv {} ready: ws={} web={} instance={} pending_migrations={}",
590                    result.pid, result.ws_endpoint, result.web_endpoint, result.instance_id,
591                    result.pending_migrations
592                ));
593                let _ = tx.send(result).await;
594                estart_sent = true;
595            } else if let Some(reason) =
596                agentmux_common::srv_stderr::parse_migration_failed_line(&line)
597            {
598                crate::log(&format!("[srv {} MIGRATION FAILED] {}", pid_for_log, reason));
599                let _ = failure_tx.send(reason).await;
600            } else if line.starts_with("AGENTMUXSRV-MIGRATING") {
601                crate::log(&format!("[srv {} migrating] {}", pid_for_log, line));
602                let _ = migration_tx.send(()).await;
603            } else if let Some(b) = agentmux_common::srv_stderr::parse_migration_begin_line(&line) {
604                // Sub-row against the already-open "backend" stage — a
605                // migration is not a separate top-level stage, it happens
606                // entirely inside that one. See
607                // docs/reports/REPORT_SPLASH_SCREEN_ARCHITECTURE_RETHINK_2026_09_14.md
608                // §2.1/§4 Phase 3.1.
609                sink_for_migrations.sub_begin("backend", b.id, b.description);
610            } else if let Some(e) = agentmux_common::srv_stderr::parse_migration_end_line(&line) {
611                sink_for_migrations.sub_end(
612                    "backend",
613                    e.id,
614                    e.duration_ms,
615                    crate::startup_events::StartupStatus::Ok,
616                    None,
617                );
618            } else if line.starts_with("AGENTMUXSRV-EVENT:") {
619                crate::log(&format!("[srv {} event] {}", pid_for_log, line));
620                // Phase B.2 will forward these to subscribers.
621            } else {
622                crate::log(&format!("[srv {} stderr] {}", pid_for_log, line));
623            }
624        }
625        // EOF on stderr → srv exited (or its stderr closed). Logged
626        // by the wait-task in main; nothing else to do here.
627    });
628
629    // Wait for ESTART. Both error paths must explicitly start_kill
630    // the child before returning — same kill_on_drop(false) leak
631    // class as the assign/resume failures above. Without this, the
632    // 30s timeout in degraded mode (J0 absent) would leak a fully-
633    // running srv that keeps the data dir lockfile, blocking the
634    // next launch (#571).
635    //
636    // If srv emits AGENTMUXSRV-MIGRATING before ESTART, the deadline is
637    // extended to 30 minutes to accommodate large-dataset migrations. A shorter
638    // cap risks killing srv mid-migration: 0011_shared_store_backfill's skip-guards
639    // check for non-empty shared tables, so a partial copy followed by a kill would
640    // cause rows to be permanently stranded on the next retry.
641    let normal_timeout = std::time::Duration::from_secs(30);
642    let migration_timeout = std::time::Duration::from_secs(1800);
643    let mut deadline = tokio::time::Instant::now() + normal_timeout;
644    let sleep = tokio::time::sleep_until(deadline);
645    tokio::pin!(sleep);
646
647    let recv = loop {
648        tokio::select! {
649            // `biased;` — poll arms in declaration order, failure first.
650            // The stderr reader sends the failure reason and then hits EOF
651            // (srv exits 1 right after the line), dropping every sender
652            // before this select! is next polled. Unbiased, `rx.recv()`
653            // resolving to None (closed) could win the coin flip over the
654            // already-buffered reason and degrade this to the generic
655            // EstartChannelClosed — invisible on Windows, exactly what the
656            // MigrationFailed path exists to fix (reagent P1, post-merge on
657            // #3043). `prefer_buffered_failure` below is the second guard.
658            biased;
659            Some(reason) = failure_rx.recv() => break EstartWait::MigrationFailed(reason),
660            result = rx.recv() => break EstartWait::Ready(result),
661            _ = &mut sleep => break EstartWait::Timeout,
662            Some(()) = migration_rx.recv() => {
663                // Migrations are running — extend the deadline generously.
664                let new_deadline = tokio::time::Instant::now() + migration_timeout;
665                if new_deadline > deadline {
666                    deadline = new_deadline;
667                    sleep.as_mut().reset(deadline);
668                    crate::log("srv: migration in progress — ESTART deadline extended to 30 minutes");
669                }
670            }
671        }
672    };
673    let recv = prefer_buffered_failure(recv, &mut failure_rx);
674    match recv {
675        EstartWait::Timeout => {
676            let _ = child.start_kill();
677            sink.stage_end(
678                "backend",
679                srv_t.elapsed().as_millis() as u64,
680                crate::startup_events::StartupStatus::Error,
681                Some("timeout (30s)".into()),
682            );
683            Err(SrvSpawnError::EstartTimeout)
684        }
685        EstartWait::MigrationFailed(reason) => {
686            // srv exits itself right after the line; start_kill on an
687            // already-exited child is a harmless no-op and keeps this arm in
688            // the same shape as the other two failure paths.
689            let _ = child.start_kill();
690            sink.stage_end(
691                "backend",
692                srv_t.elapsed().as_millis() as u64,
693                crate::startup_events::StartupStatus::Error,
694                Some("migration failed".into()),
695            );
696            Err(SrvSpawnError::MigrationFailed(reason))
697        }
698        EstartWait::Ready(None) => {
699            let _ = child.start_kill();
700            sink.stage_end(
701                "backend",
702                srv_t.elapsed().as_millis() as u64,
703                crate::startup_events::StartupStatus::Error,
704                None,
705            );
706            Err(SrvSpawnError::EstartChannelClosed)
707        }
708        EstartWait::Ready(Some(result)) => {
709            sink.stage_end(
710                "backend",
711                srv_t.elapsed().as_millis() as u64,
712                crate::startup_events::StartupStatus::Ok,
713                None,
714            );
715            Ok((result, child))
716        }
717    }
718}
719
720/// Resolve the agentmux-srv binary path from the LAUNCHER's vantage
721/// point.
722///
723/// Search order, mirroring the host's `resolve_backend_binary` in
724/// sidecar.rs (minus its workspace `dist/bin` fallback) but anchored at
725/// the launcher's exe dir:
726///   1. `<launcher_dir>/runtime/agentmux-srv-{ver}-{os}.{arch}.exe`
727///      (versioned portable layout)
728///   2. `<launcher_dir>/runtime/agentmux-srv.exe` (dev fallback)
729///   3. `<launcher_dir>/agentmux-srv-{ver}-{os}.{arch}.exe`
730///      (launcher in same dir as srv — should not happen in portable
731///      but covers cargo-built dev mode where launcher + srv both
732///      land in target/release/)
733///   4. `<launcher_dir>/agentmux-srv.exe` (dev fallback)
734fn resolve_srv_binary(launcher_exe_dir: &Path) -> Result<PathBuf, SrvSpawnError> {
735    let backend_name = "agentmux-srv";
736    let exe_suffix = if cfg!(target_os = "windows") { ".exe" } else { "" };
737    let version = env!("CARGO_PKG_VERSION");
738    let (os_name, arch) = if cfg!(target_os = "macos") {
739        ("darwin", if cfg!(target_arch = "aarch64") { "arm64" } else { "x64" })
740    } else if cfg!(target_os = "linux") {
741        ("linux", if cfg!(target_arch = "aarch64") { "arm64" } else { "x64" })
742    } else {
743        ("windows", if cfg!(target_arch = "aarch64") { "arm64" } else { "x64" })
744    };
745
746    let candidates = [
747        // Portable: srv lives in launcher_dir/runtime/
748        launcher_exe_dir
749            .join("runtime")
750            .join(format!("{}-{}-{}.{}{}", backend_name, version, os_name, arch, exe_suffix)),
751        launcher_exe_dir
752            .join("runtime")
753            .join(format!("{}{}", backend_name, exe_suffix)),
754        // Dev: launcher and srv side-by-side in target/release/
755        launcher_exe_dir
756            .join(format!("{}-{}-{}.{}{}", backend_name, version, os_name, arch, exe_suffix)),
757        launcher_exe_dir.join(format!("{}{}", backend_name, exe_suffix)),
758    ];
759
760    for p in &candidates {
761        if p.exists() {
762            return Ok(p.clone());
763        }
764    }
765
766    Err(SrvSpawnError::BinaryNotFound(format!(
767        "{} v{} not found. Searched:\n  {}",
768        backend_name,
769        version,
770        candidates
771            .iter()
772            .map(|p| p.display().to_string())
773            .collect::<Vec<_>>()
774            .join("\n  ")
775    )))
776}
777
778/// Parsed fields out of a `AGENTMUXSRV-ESTART` line. Same shape as the
779/// host's `parse_estart` in sidecar.rs, minus `version`.
780struct EstartFields {
781    ws_endpoint: String,
782    web_endpoint: String,
783    instance_id: String,
784    pending_migrations: usize,
785}
786
787fn parse_estart(line: &str) -> EstartFields {
788    let parts: Vec<&str> = line.split_whitespace().collect();
789    let get = |prefix: &str| -> String {
790        parts
791            .iter()
792            .find_map(|p| p.strip_prefix(prefix))
793            .unwrap_or_default()
794            .to_string()
795    };
796    let pending_migrations = parts
797        .iter()
798        .find_map(|p| p.strip_prefix("pending_migrations:"))
799        .and_then(|s| s.parse::<usize>().ok())
800        .unwrap_or(0);
801    EstartFields {
802        ws_endpoint: get("ws:"),
803        web_endpoint: get("web:"),
804        instance_id: get("instance:"),
805        pending_migrations,
806    }
807}
808
809/// Assign a process to the launcher's Job Object J0. Used by
810/// `spawn_srv` for srv and exported for `main.rs` to use for the
811/// host. Separated from job creation because both children join
812/// the SAME job (only one J0 ever exists per launcher run).
813#[cfg(target_os = "windows")]
814pub fn assign_pid_to_job(
815    pid: u32,
816    job: windows_sys::Win32::Foundation::HANDLE,
817) -> Result<(), String> {
818    use windows_sys::Win32::Foundation::CloseHandle;
819    use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject;
820    use windows_sys::Win32::System::Threading::{
821        OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE,
822    };
823    unsafe {
824        let process = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid);
825        if process.is_null() {
826            return Err(format!("OpenProcess({}) returned null", pid));
827        }
828        let ok = AssignProcessToJobObject(job, process);
829        CloseHandle(process);
830        if ok == 0 {
831            return Err(format!(
832                "AssignProcessToJobObject failed for pid={}",
833                pid
834            ));
835        }
836        Ok(())
837    }
838}
839
840#[cfg(test)]
841mod migration_failed_tests {
842    use super::*;
843
844    #[test]
845    fn display_names_the_migration_and_carries_srv_reason() {
846        let e = SrvSpawnError::MigrationFailed("migration 0007 failed: disk I/O error".into());
847        assert_eq!(e.to_string(), "database migration failed: migration 0007 failed: disk I/O error");
848    }
849
850    #[test]
851    fn dialog_body_includes_reason_and_restart_guidance() {
852        let body = migration_failed_dialog_body("open shared store: database is locked");
853        assert!(body.contains("open shared store: database is locked"));
854        assert!(body.contains("Restart AgentMux to retry"));
855        assert!(body.contains("shared/backups/pre-migration-"), "must say where the pre-run copy lives");
856    }
857
858    #[test]
859    fn dialog_body_never_asserts_a_backup_exists() {
860        // reagent P1 on #3043: `backup_stores` failing is itself one of the
861        // error paths, so the body must not promise a snapshot/backup was
862        // taken. The wording states the ordering (copy first, then migrate)
863        // and lets the reason text — which names the backup step when that
864        // is what failed — carry the rest.
865        let body = migration_failed_dialog_body("run_pending_migrations: backup failed: disk full");
866        assert!(!body.contains("has not been changed:"), "unconditional data-safety claim");
867        assert!(!body.contains("a snapshot was taken"), "unconditional snapshot claim");
868        assert!(body.contains("if the error above is about writing that copy, nothing was changed"));
869    }
870
871    #[test]
872    fn stderr_line_from_srv_parses_into_the_reason_the_dialog_shows() {
873        // End-to-end over the shared protocol: what srv writes is what the
874        // launcher reads, without either side owning the literal.
875        let line = agentmux_common::srv_stderr::migration_failed_line("mark applied 0011: readonly db");
876        let reason = agentmux_common::srv_stderr::parse_migration_failed_line(&line).expect("tagged line");
877        assert_eq!(reason, "mark applied 0011: readonly db");
878        assert!(!line.starts_with("AGENTMUXSRV-MIGRATING"), "must not be swallowed by the MIGRATING branch");
879    }
880}