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}