Skip to main content

agentmux_launcher/
diag.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Phase B.8 — `agentmux.exe --diag wrr` Tool client.
5//
6// Connects to a running AgentMux instance's IPC pipe as a `Tool`
7// client, registers, captures events for a short observation
8// window, prints a human-readable summary to stdout, and exits.
9//
10// Operator visibility into the launcher's reducer state without
11// needing a debugger or log scraping. The output IS the diagnosis
12// surface for "what does the launcher think is happening right now."
13//
14// Why Tool kind: doesn't drive the reducer's lifecycle (Host
15// drives Starting → Running), doesn't trigger OrphanInstance saga,
16// gets all events broadcast on the pipe without affecting the
17// running instance.
18//
19// Phase D.1 — `Command::GetSnapshot` is sent right after Register
20// to capture the launcher's canonical state in one round-trip; the
21// reply (`Event::Snapshot`) is printed prominently before the live
22// event stream. Phase D.2 + D.3 will add a persisted event log + a
23// `since: u64` parameter for delta replay; until then a snapshot is
24// "as-of-now" only.
25
26#[cfg(target_os = "windows")]
27use std::time::Duration;
28
29#[cfg(target_os = "windows")]
30use agentmux_common::ipc::{ClientKind, Command, Event};
31#[cfg(target_os = "windows")]
32use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
33
34#[cfg(target_os = "windows")]
35use tokio::net::windows::named_pipe::ClientOptions;
36
37#[cfg(target_os = "windows")]
38const OBSERVATION_WINDOW: Duration = Duration::from_secs(2);
39
40/// Entry point for diag mode. Returns `Ok(())` on success (printed
41/// summary, exit 0) or `Err(message)` for the caller to surface and
42/// exit non-zero.
43#[cfg(target_os = "windows")]
44pub async fn run_wrr_diag(launcher_exe_dir: &std::path::Path) -> Result<(), String> {
45    let version = env!("CARGO_PKG_VERSION");
46
47    let paths = crate::data_dir::resolve_paths(launcher_exe_dir, version)
48        .map_err(|e| format!("path resolution failed: {}", e))?;
49    let dir_hash = crate::hash::data_dir_hash16(&paths.data_dir, version);
50    let pipe_path = crate::ipc::pipe_name(&dir_hash);
51
52    println!("AgentMux diagnostic — connecting to {}", pipe_path);
53    println!("Data dir: {}", paths.data_dir.display());
54
55    let client = ClientOptions::new()
56        .open(&pipe_path)
57        .map_err(|e| format!(
58            "could not open pipe {}: {} (is AgentMux running for this data dir?)",
59            pipe_path, e
60        ))?;
61    println!("Connected. Registering as Tool client...\n");
62
63    let (read_half, mut write_half) = tokio::io::split(client);
64
65    let register = Command::Register {
66        kind: ClientKind::Tool,
67        pid: std::process::id(),
68        version: version.to_string(),
69    };
70    let mut buf = serde_json::to_vec(&register)
71        .map_err(|e| format!("serialize Register: {}", e))?;
72    buf.push(b'\n');
73    write_half.write_all(&buf).await
74        .map_err(|e| format!("send Register: {}", e))?;
75    write_half.flush().await
76        .map_err(|e| format!("flush Register: {}", e))?;
77
78    // Phase D.1 — request a state snapshot. Reply arrives on the
79    // broadcast bus alongside the Register reply; we filter for it
80    // in the event collection below.
81    let mut buf = serde_json::to_vec(&Command::GetSnapshot)
82        .map_err(|e| format!("serialize GetSnapshot: {}", e))?;
83    buf.push(b'\n');
84    write_half.write_all(&buf).await
85        .map_err(|e| format!("send GetSnapshot: {}", e))?;
86    write_half.flush().await
87        .map_err(|e| format!("flush GetSnapshot: {}", e))?;
88
89    // Phase D.3 — request a replay of all retained events
90    // (`since: 0` means everything in the in-memory ring). Useful
91    // for operators to see the full event history of the running
92    // launcher without having to wait for new activity. The reply
93    // is an `Event::EventList`; rendered in the summary below.
94    let mut buf = serde_json::to_vec(&Command::GetEvents { since: 0 })
95        .map_err(|e| format!("serialize GetEvents: {}", e))?;
96    buf.push(b'\n');
97    write_half.write_all(&buf).await
98        .map_err(|e| format!("send GetEvents: {}", e))?;
99    write_half.flush().await
100        .map_err(|e| format!("flush GetEvents: {}", e))?;
101
102    // Read events for OBSERVATION_WINDOW. The launcher's IPC server
103    // (post-B.8) broadcasts every reducer event on a server-wide
104    // bus; this connection's fanout writes them to us. We collect
105    // everything that arrives in the window and print a summary.
106    let reader = BufReader::new(read_half);
107    let mut lines = reader.lines();
108    let mut events: Vec<Event> = Vec::new();
109    let deadline = tokio::time::Instant::now() + OBSERVATION_WINDOW;
110
111    loop {
112        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
113        if remaining.is_zero() {
114            break;
115        }
116        match tokio::time::timeout(remaining, lines.next_line()).await {
117            Ok(Ok(Some(line))) if line.trim().is_empty() => continue,
118            Ok(Ok(Some(line))) => {
119                match serde_json::from_str::<Event>(&line) {
120                    Ok(evt) => events.push(evt),
121                    Err(e) => eprintln!("[warn] could not parse event: {} ({})", e, line),
122                }
123            }
124            Ok(Ok(None)) => {
125                eprintln!("[warn] pipe closed before observation window elapsed");
126                break;
127            }
128            Ok(Err(e)) => {
129                return Err(format!("read error: {}", e));
130            }
131            Err(_) => break, // timeout → observation window closed
132        }
133    }
134
135    // Phase B.8 — send Goodbye so the server emits ProcessExited and
136    // marks our PID record as Exited. Without this, repeated
137    // `--diag wrr` invocations accumulate stale `Running` records;
138    // when Windows reuses a PID, the next Register fails with
139    // `AlreadyRegistered`. Best-effort: errors are non-fatal because
140    // we're about to exit anyway. (codex P2 PR #605.)
141    let goodbye = match serde_json::to_vec(&Command::Goodbye) {
142        Ok(mut b) => { b.push(b'\n'); b }
143        Err(_) => Vec::new(),
144    };
145    if !goodbye.is_empty() {
146        let _ = write_half.write_all(&goodbye).await;
147        let _ = write_half.flush().await;
148    }
149
150    print_summary(&events);
151    Ok(())
152}
153
154/// Stub for non-Windows platforms. Phase 7 cross-platform IPC will
155/// implement Unix domain sockets; until then `--diag wrr` is
156/// Windows-only.
157#[cfg(not(target_os = "windows"))]
158pub async fn run_wrr_diag(_launcher_exe_dir: &std::path::Path) -> Result<(), String> {
159    Err("--diag wrr is Windows-only (Phase 7 will add Unix domain socket parity)".to_string())
160}
161
162/// Phase E.7 — `agentmux.exe --diag srv` Tool client.
163///
164/// Connects to the srv pipe (`\\.\pipe\agentmux-{hash}\srv-command`),
165/// registers as a Tool client, sends `GetSrvSnapshot` + `GetEvents`,
166/// captures events for the same 2 s observation window as
167/// `--diag wrr`, prints a human-readable summary, exits.
168///
169/// Provides operator visibility into the srv reducer's canonical
170/// state (workspaces / tabs / blocks / windows / saga lifecycle) +
171/// recent event activity. Mirrors `run_wrr_diag` for the launcher;
172/// the differences are the pipe path, the snapshot variant
173/// (`Event::SrvSnapshot`), and the formatter for srv-specific event
174/// kinds.
175#[cfg(target_os = "windows")]
176pub async fn run_srv_diag(launcher_exe_dir: &std::path::Path) -> Result<(), String> {
177    let version = env!("CARGO_PKG_VERSION");
178
179    let paths = crate::data_dir::resolve_paths(launcher_exe_dir, version)
180        .map_err(|e| format!("path resolution failed: {}", e))?;
181    let dir_hash = crate::hash::data_dir_hash16(&paths.data_dir, version);
182    let pipe_path = crate::ipc::srv_pipe_name(&dir_hash);
183
184    println!("AgentMux srv diagnostic — connecting to {}", pipe_path);
185    println!("Data dir: {}", paths.data_dir.display());
186
187    let client = ClientOptions::new().open(&pipe_path).map_err(|e| {
188        format!(
189            "could not open srv pipe {}: {} (is AgentMux running for this data dir? \
190             srv may not be bound in `task dev` mode)",
191            pipe_path, e
192        )
193    })?;
194    println!("Connected. Registering as Tool client...\n");
195
196    let (read_half, mut write_half) = tokio::io::split(client);
197
198    let register = Command::Register {
199        kind: ClientKind::Tool,
200        pid: std::process::id(),
201        version: version.to_string(),
202    };
203    let mut buf = serde_json::to_vec(&register)
204        .map_err(|e| format!("serialize Register: {}", e))?;
205    buf.push(b'\n');
206    write_half
207        .write_all(&buf)
208        .await
209        .map_err(|e| format!("send Register: {}", e))?;
210    write_half
211        .flush()
212        .await
213        .map_err(|e| format!("flush Register: {}", e))?;
214
215    // Phase E.1b — request the srv reducer's canonical state.
216    let mut buf = serde_json::to_vec(&Command::GetSrvSnapshot)
217        .map_err(|e| format!("serialize GetSrvSnapshot: {}", e))?;
218    buf.push(b'\n');
219    write_half
220        .write_all(&buf)
221        .await
222        .map_err(|e| format!("send GetSrvSnapshot: {}", e))?;
223    write_half
224        .flush()
225        .await
226        .map_err(|e| format!("flush GetSrvSnapshot: {}", e))?;
227
228    // Phase D.3 / E.7 — request a replay of all retained events from
229    // the srv side's in-memory ring + on-disk event log. Useful for
230    // operators wanting recent srv reducer activity (saga lifecycle
231    // events, workspace/tab/block mutations) without having to wait
232    // for new activity.
233    let mut buf = serde_json::to_vec(&Command::GetEvents { since: 0 })
234        .map_err(|e| format!("serialize GetEvents: {}", e))?;
235    buf.push(b'\n');
236    write_half
237        .write_all(&buf)
238        .await
239        .map_err(|e| format!("send GetEvents: {}", e))?;
240    write_half
241        .flush()
242        .await
243        .map_err(|e| format!("flush GetEvents: {}", e))?;
244
245    let reader = BufReader::new(read_half);
246    let mut lines = reader.lines();
247    let mut events: Vec<Event> = Vec::new();
248    let deadline = tokio::time::Instant::now() + OBSERVATION_WINDOW;
249
250    loop {
251        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
252        if remaining.is_zero() {
253            break;
254        }
255        match tokio::time::timeout(remaining, lines.next_line()).await {
256            Ok(Ok(Some(line))) if line.trim().is_empty() => continue,
257            Ok(Ok(Some(line))) => match serde_json::from_str::<Event>(&line) {
258                Ok(evt) => events.push(evt),
259                Err(e) => eprintln!("[warn] could not parse event: {} ({})", e, line),
260            },
261            Ok(Ok(None)) => {
262                eprintln!("[warn] srv pipe closed before observation window elapsed");
263                break;
264            }
265            Ok(Err(e)) => {
266                return Err(format!("read error: {}", e));
267            }
268            Err(_) => break,
269        }
270    }
271
272    // Best-effort Goodbye so the srv server marks our PID Exited
273    // (same rationale as run_wrr_diag).
274    let goodbye = match serde_json::to_vec(&Command::Goodbye) {
275        Ok(mut b) => {
276            b.push(b'\n');
277            b
278        }
279        Err(_) => Vec::new(),
280    };
281    if !goodbye.is_empty() {
282        let _ = write_half.write_all(&goodbye).await;
283        let _ = write_half.flush().await;
284    }
285
286    print_srv_summary(&events);
287    Ok(())
288}
289
290#[cfg(not(target_os = "windows"))]
291pub async fn run_srv_diag(_launcher_exe_dir: &std::path::Path) -> Result<(), String> {
292    Err("--diag srv is Windows-only (Phase 7 will add Unix domain socket parity)".to_string())
293}
294
295#[cfg(target_os = "windows")]
296fn print_srv_summary(events: &[Event]) {
297    let snapshot: Vec<&Event> = events
298        .iter()
299        .filter(|e| matches!(e, Event::SrvSnapshot { .. }))
300        .collect();
301    let replay: Vec<&Event> = events
302        .iter()
303        .filter(|e| matches!(e, Event::EventList { .. }))
304        .collect();
305    let stream: Vec<&Event> = events
306        .iter()
307        .filter(|e| !matches!(e, Event::SrvSnapshot { .. } | Event::EventList { .. }))
308        .collect();
309
310    if let Some(Event::SrvSnapshot {
311        version,
312        lifecycle,
313        workspaces,
314        tabs,
315        active_tabs,
316        blocks,
317    }) = snapshot.last().copied()
318    {
319        println!("=== SrvSnapshot (event_version={}) ===", version);
320        println!("Lifecycle: {:?}", lifecycle);
321        println!();
322        println!("Workspaces ({}):", workspaces.len());
323        if workspaces.is_empty() {
324            println!("  (none)");
325        } else {
326            for (id, name) in workspaces {
327                let active = active_tabs
328                    .iter()
329                    .find(|(ws, _)| ws == id)
330                    .map(|(_, t)| t.as_str())
331                    .unwrap_or("—");
332                let tab_count = tabs.iter().filter(|(_, ws, _)| ws == id).count();
333                println!(
334                    "  {:36} name={:<20} tabs={} active={}",
335                    id, name, tab_count, active
336                );
337            }
338        }
339        println!();
340        println!("Tabs ({}):", tabs.len());
341        if tabs.is_empty() {
342            println!("  (none)");
343        } else {
344            for (tab_id, ws_id, name) in tabs {
345                let block_count = blocks.iter().filter(|(_, t)| t == tab_id).count();
346                println!(
347                    "  {:36} ws={:36} name={:<16} blocks={}",
348                    tab_id, ws_id, name, block_count
349                );
350            }
351        }
352        println!();
353        println!("Blocks ({}):", blocks.len());
354        if blocks.is_empty() {
355            println!("  (none)");
356        } else {
357            for (block_id, tab_id) in blocks {
358                println!("  {:36} tab={}", block_id, tab_id);
359            }
360        }
361        println!();
362    } else {
363        println!("(SrvSnapshot not received — srv pipe may not be bound, or older srv build)");
364        println!();
365    }
366
367    if let Some(Event::EventList {
368        events: replay_events,
369        version,
370    }) = replay.last().copied()
371    {
372        println!(
373            "=== EventList replay (event_version={}, {} event(s)) ===",
374            version,
375            replay_events.len()
376        );
377        if replay_events.is_empty() {
378            println!("(empty — srv ring + event log contained no events)");
379        } else {
380            // Show the last 20 events to keep output manageable.
381            let to_show = replay_events.iter().rev().take(20).collect::<Vec<_>>();
382            let n = replay_events.len();
383            let skipped = n.saturating_sub(to_show.len());
384            if skipped > 0 {
385                println!("(showing last 20 of {})", n);
386            }
387            for (i, evt) in to_show.iter().rev().enumerate() {
388                println!("  [{}] {}", skipped + i, format_srv_event(evt));
389            }
390        }
391        println!();
392    }
393
394    // Saga activity is the most operator-relevant signal here.
395    use std::collections::BTreeMap;
396    let mut saga_counts: BTreeMap<&'static str, u32> = BTreeMap::new();
397    let stream_or_replay = events
398        .iter()
399        .filter(|e| !matches!(e, Event::SrvSnapshot { .. }))
400        .flat_map(|e| match e {
401            Event::EventList { events: inner, .. } => inner.iter().collect::<Vec<_>>(),
402            other => vec![other],
403        });
404    for evt in stream_or_replay {
405        match evt {
406            Event::SagaStarted { .. } => *saga_counts.entry("SagaStarted").or_insert(0) += 1,
407            Event::SagaCompleted { .. } => {
408                *saga_counts.entry("SagaCompleted").or_insert(0) += 1
409            }
410            Event::SagaFailed { .. } => *saga_counts.entry("SagaFailed").or_insert(0) += 1,
411            _ => {}
412        }
413    }
414    if !saga_counts.is_empty() {
415        println!("=== Saga lifecycle (across snapshot + replay + stream) ===");
416        for (kind, count) in &saga_counts {
417            println!("  {:>4}× {}", count, kind);
418        }
419        println!();
420    }
421
422    println!(
423        "=== Live stream observed in {}s ===",
424        OBSERVATION_WINDOW.as_secs()
425    );
426    if stream.is_empty() {
427        println!("(no events — srv reducer is idle.)");
428        return;
429    }
430    let mut counts: BTreeMap<&'static str, u32> = BTreeMap::new();
431    for evt in &stream {
432        *counts.entry(srv_event_kind_name(evt)).or_insert(0) += 1;
433    }
434    println!("By kind:");
435    for (kind, count) in &counts {
436        println!("  {:>4}× {}", count, kind);
437    }
438    println!();
439    println!("Full stream (oldest first):");
440    for (i, evt) in stream.iter().enumerate() {
441        println!("  [{}] {}", i, format_srv_event(evt));
442    }
443}
444
445#[cfg(target_os = "windows")]
446fn srv_event_kind_name(e: &Event) -> &'static str {
447    match e {
448        Event::Registered { .. } => "Registered",
449        Event::ProcessSpawned { .. } => "ProcessSpawned",
450        Event::ProcessExited { .. } => "ProcessExited",
451        Event::LifecyclePhaseChanged { .. } => "LifecyclePhaseChanged",
452        Event::SagaStarted { .. } => "SagaStarted",
453        Event::SagaCompleted { .. } => "SagaCompleted",
454        Event::SagaFailed { .. } => "SagaFailed",
455        Event::WorkspaceCreated { .. } => "WorkspaceCreated",
456        Event::WorkspaceDeleted { .. } => "WorkspaceDeleted",
457        Event::WorkspaceRenamed { .. } => "WorkspaceRenamed",
458        Event::WorkspaceMetaUpdated { .. } => "WorkspaceMetaUpdated",
459        Event::TabCreated { .. } => "TabCreated",
460        Event::TabDeleted { .. } => "TabDeleted",
461        Event::TabRenamed { .. } => "TabRenamed",
462        Event::TabReordered { .. } => "TabReordered",
463        Event::TabsReorderedBulk { .. } => "TabsReorderedBulk",
464        Event::TabMoved { .. } => "TabMoved",
465        Event::TabMetaUpdated { .. } => "TabMetaUpdated",
466        Event::ActiveTabChanged { .. } => "ActiveTabChanged",
467        Event::BlockCreated { .. } => "BlockCreated",
468        Event::BlockDeleted { .. } => "BlockDeleted",
469        Event::BlockMoved { .. } => "BlockMoved",
470        Event::BlockMetaUpdated { .. } => "BlockMetaUpdated",
471        Event::SrvWindowOpened { .. } => "SrvWindowOpened",
472        Event::SrvWindowClosed { .. } => "SrvWindowClosed",
473        Event::SrvWindowWorkspaceChanged { .. } => "SrvWindowWorkspaceChanged",
474        Event::SrvSnapshot { .. } => "SrvSnapshot",
475        Event::EventList { .. } => "EventList",
476        Event::Error { .. } => "Error",
477        _ => "Other",
478    }
479}
480
481#[cfg(target_os = "windows")]
482fn format_srv_event(e: &Event) -> String {
483    match e {
484        Event::SagaStarted { saga_id, name, version } => {
485            format!("v={:>3} SagaStarted        id={} name={}", version, saga_id, name)
486        }
487        Event::SagaCompleted { saga_id, version } => {
488            format!("v={:>3} SagaCompleted      id={}", version, saga_id)
489        }
490        Event::SagaFailed { saga_id, reason, version } => {
491            format!("v={:>3} SagaFailed         id={} reason={}", version, saga_id, reason)
492        }
493        Event::WorkspaceCreated { workspace_id, name, version } => {
494            format!("v={:>3} WorkspaceCreated   id={} name={}", version, workspace_id, name)
495        }
496        Event::WorkspaceDeleted { workspace_id, version, .. } => {
497            format!("v={:>3} WorkspaceDeleted   id={}", version, workspace_id)
498        }
499        Event::TabCreated { workspace_id, tab_id, name, version } => {
500            format!("v={:>3} TabCreated         tab={} ws={} name={}", version, tab_id, workspace_id, name)
501        }
502        Event::TabMoved {
503            tab_id,
504            src_workspace_id,
505            dst_workspace_id,
506            dst_index,
507            version,
508            ..
509        } => format!(
510            "v={:>3} TabMoved           tab={} {} → {} idx={}",
511            version, tab_id, src_workspace_id, dst_workspace_id, dst_index
512        ),
513        Event::BlockMoved { block_id, src_tab_id, dst_tab_id, dst_index, version } => format!(
514            "v={:>3} BlockMoved         blk={} {} → {} idx={}",
515            version, block_id, src_tab_id, dst_tab_id, dst_index
516        ),
517        Event::Error { code, message, version, .. } => {
518            format!("v={:>3} Error              code={:?} msg={}", version, code, message)
519        }
520        other => serde_json::to_string(other).unwrap_or_else(|_| format!("{:?}", other)),
521    }
522}
523
524#[cfg(target_os = "windows")]
525fn print_summary(events: &[Event]) {
526    // Phase D.1 — pull the Snapshot out and print it first as the
527    // canonical "state now" view.
528    // Phase D.3 — pull the EventList replay out next; what's left
529    // is the live broadcast stream observed during the 2s window.
530    let snapshot: Vec<&Event> = events
531        .iter()
532        .filter(|e| matches!(e, Event::Snapshot { .. }))
533        .collect();
534    let replay: Vec<&Event> = events
535        .iter()
536        .filter(|e| matches!(e, Event::EventList { .. }))
537        .collect();
538    let stream: Vec<&Event> = events
539        .iter()
540        .filter(|e| {
541            !matches!(
542                e,
543                Event::Snapshot { .. } | Event::EventList { .. }
544            )
545        })
546        .collect();
547
548    // Pick the LATEST snapshot in the captured set. The IPC server
549    // broadcasts snapshots to all subscribers, so concurrent diag/Tool
550    // clients can produce multiple Snapshot events in this window;
551    // using `.first()` would show the oldest (potentially another
552    // client's stale view) rather than ours. `.last()` biases toward
553    // freshness — our own snapshot reply is monotonically the latest
554    // among captured events on the broadcast bus. (codex P2 PR #607.)
555    if let Some(Event::Snapshot {
556        version,
557        lifecycle,
558        windows,
559        pool,
560        instance_registry,
561        backend_window_ids,
562        monitors,
563    }) = snapshot.last().copied()
564    {
565        println!("=== Snapshot (event_version={}) ===", version);
566        println!("Lifecycle: {:?}", lifecycle);
567        println!("Monitors:  {} ({:?})", monitors.len(), monitors);
568        println!();
569        println!("Windows ({}):", windows.len());
570        if windows.is_empty() {
571            println!("  (none)");
572        } else {
573            for w in windows {
574                let inst = instance_registry
575                    .iter()
576                    .find(|(l, _)| l == &w.label)
577                    .map(|(_, n)| format!("#{}", n))
578                    .unwrap_or_else(|| "—".to_string());
579                let backend = backend_window_ids
580                    .iter()
581                    .find(|(l, _)| l == &w.label)
582                    .map(|(_, b)| b.as_str())
583                    .unwrap_or("—");
584                println!(
585                    "  {:>4} {:30} kind={:?} hwnd={:?} visible={} iconic={} fg_seen={} backend={}",
586                    inst, w.label, w.kind, w.hwnd, w.visible, w.iconic,
587                    w.foregrounded_since_open, backend
588                );
589            }
590        }
591        println!();
592        println!("Pool ({}): {:?}", pool.len(), pool);
593        println!();
594    } else {
595        println!("(snapshot not received — server may be older than D.1)");
596        println!();
597    }
598
599    // Phase D.3 — replay slice from the launcher's in-memory event
600    // ring. Useful for operators wanting recent history; for
601    // resyncing subscribers, this is everything since their last
602    // seen version.
603    if let Some(Event::EventList { events: replay_events, version }) = replay.last().copied() {
604        println!("=== EventList replay (event_version={}, {} event(s)) ===", version, replay_events.len());
605        if replay_events.is_empty() {
606            println!("(empty — launcher's in-memory ring contained no events with version > 0)");
607        } else {
608            for (i, evt) in replay_events.iter().enumerate() {
609                println!("  [{}] {}", i, format_event(evt));
610            }
611        }
612        println!();
613    }
614
615    println!("=== Live stream observed in {}s ===", OBSERVATION_WINDOW.as_secs());
616    if stream.is_empty() {
617        println!("(no events — instance is idle.)");
618        return;
619    }
620    use std::collections::BTreeMap;
621    let mut counts: BTreeMap<&'static str, u32> = BTreeMap::new();
622    for evt in &stream {
623        *counts.entry(event_kind_name(evt)).or_insert(0) += 1;
624    }
625    println!("By kind:");
626    for (kind, count) in &counts {
627        println!("  {:>4}× {}", count, kind);
628    }
629    println!();
630    println!("Full stream (oldest first):");
631    for (i, evt) in stream.iter().enumerate() {
632        println!("  [{}] {}", i, format_event(evt));
633    }
634}
635
636#[cfg(target_os = "windows")]
637fn event_kind_name(e: &Event) -> &'static str {
638    match e {
639        Event::Registered { .. } => "Registered",
640        Event::Pong { .. } => "Pong",
641        Event::ProcessSpawned { .. } => "ProcessSpawned",
642        Event::ProcessExited { .. } => "ProcessExited",
643        Event::LifecyclePhaseChanged { .. } => "LifecyclePhaseChanged",
644        Event::WindowOpened { .. } => "WindowOpened",
645        Event::WindowClosed { .. } => "WindowClosed",
646        Event::WindowInstanceAssigned { .. } => "WindowInstanceAssigned",
647        Event::WindowInstanceReleased { .. } => "WindowInstanceReleased",
648        Event::PoolWindowAdded { .. } => "PoolWindowAdded",
649        Event::PoolWindowRemoved { .. } => "PoolWindowRemoved",
650        Event::PoolWindowPromoted { .. } => "PoolWindowPromoted",
651        Event::BackendWindowIdRegistered { .. } => "BackendWindowIdRegistered",
652        Event::BackendWindowIdUnregistered { .. } => "BackendWindowIdUnregistered",
653        Event::DriftDetected { .. } => "DriftDetected",
654        Event::HwndDriftDetected { .. } => "HwndDriftDetected",
655        Event::CorrectiveWindowMove { .. } => "CorrectiveWindowMove",
656        Event::HostShouldQuit { .. } => "HostShouldQuit",
657        Event::Snapshot { .. } => "Snapshot",
658        Event::EventList { .. } => "EventList",
659        Event::SagaStarted { .. } => "SagaStarted",
660        Event::SagaCompleted { .. } => "SagaCompleted",
661        Event::SagaFailed { .. } => "SagaFailed",
662        _ => "Other",
663    }
664}
665
666#[cfg(target_os = "windows")]
667fn format_event(e: &Event) -> String {
668    // Compact one-liner per event. Falls back to JSON for variants
669    // we don't have a custom formatter for so the operator still
670    // sees the data.
671    match e {
672        Event::WindowOpened { label, kind, parent_label, version } => format!(
673            "v={:>3} WindowOpened       label={} kind={:?} parent={:?}",
674            version, label, kind, parent_label
675        ),
676        Event::WindowClosed { label, version, crash_detected } => format!(
677            "v={:>3} WindowClosed       label={} crash_detected={}", version, label, crash_detected
678        ),
679        Event::WindowInstanceAssigned { label, num, version } => format!(
680            "v={:>3} InstanceAssigned   label={} num={}", version, label, num
681        ),
682        Event::HwndDriftDetected { kind, label, hwnd, severity, detail, version } => format!(
683            "v={:>3} HwndDriftDetected  kind={:?} label={:?} hwnd={:?} severity={:?} — {}",
684            version, kind, label, hwnd, severity, detail
685        ),
686        Event::HostShouldQuit { version } => format!(
687            "v={:>3} HostShouldQuit", version
688        ),
689        other => serde_json::to_string(other).unwrap_or_else(|_| format!("{:?}", other)),
690    }
691}
692
693// ---------------------------------------------------------------------------
694// LSD-3 — `agentmux.exe --diag sagas`
695//
696// Operator visibility into the launcher saga log
697// (`<data-dir>/db/launcher-sagas.db`). Unlike `--diag wrr` and
698// `--diag srv`, this command does NOT need a running launcher: the
699// SQLite log is a passive on-disk artefact. We open it read-only via
700// `LauncherSagaLog::open_read_only` and call `snapshot_recent(50)`. Useful when
701// the launcher won't start (operator wants to see what sagas were
702// in flight at the last crash) or post-mortem on a portable instance.
703//
704// Output mirrors the example in LSD spec §3.5 — saga header line per
705// saga, followed by step rows showing target + state + cmd snippet.
706// Sagas marked `failed_compensation` by the startup recovery walker
707// are flagged "(recovered on restart)" in the header so operators
708// can spot them at a glance.
709// ---------------------------------------------------------------------------
710
711#[cfg(target_os = "windows")]
712pub async fn run_sagas_diag(launcher_exe_dir: &std::path::Path) -> Result<(), String> {
713    run_sagas_diag_impl(launcher_exe_dir).await
714}
715
716#[cfg(not(target_os = "windows"))]
717pub async fn run_sagas_diag(launcher_exe_dir: &std::path::Path) -> Result<(), String> {
718    // The saga log is a SQLite file with no platform-specific bits;
719    // the cross-platform parity goal for `--diag sagas` is "works
720    // wherever the launcher writes the log." Phase 7 will wire the
721    // POSIX data_dir resolution, but the rest of the formatter is
722    // already platform-agnostic, so we reuse the impl on every
723    // target.
724    run_sagas_diag_impl(launcher_exe_dir).await
725}
726
727async fn run_sagas_diag_impl(launcher_exe_dir: &std::path::Path) -> Result<(), String> {
728    // Pillar 1 Step 6 (SPEC_PILLAR1_STEP6_SAGA_COLLAPSE_2026_07_16): the
729    // durable SQLite saga log this subcommand used to read was collapsed
730    // into an in-memory registry inside the running launcher — there is no
731    // on-disk saga state left for an offline inspector to open. The live
732    // narration is unchanged: every saga transition is logged to the
733    // launcher log with a `[saga]` prefix, which is the supported triage
734    // surface now.
735    let _ = launcher_exe_dir;
736    println!("AgentMux launcher saga diagnostic");
737    println!();
738    println!("The durable saga log was removed (Pillar 1 Step 6, 2026-07-16).");
739    println!("Launcher sagas now live in an in-memory registry inside the running");
740    println!("launcher; nothing is persisted for offline inspection.");
741    println!();
742    println!("To inspect saga activity, read the launcher log's [saga] lines:");
743    println!("    muxlog launcher | grep saga");
744    println!();
745    println!("Historical sagas.db files from older versions are deleted at first");
746    println!("startup of this version; any remaining copies are stale.");
747    Ok(())
748}
749
750#[cfg(test)]
751mod sagas_diag_tests {
752    use crate::saga::{LauncherSagaLog, PipeTarget};
753    use agentmux_common::ipc::{Command, Event};
754
755    /// Smoke test: build a fixture saga log, run `snapshot_recent`
756    /// and `unresolved_sagas`, and verify the formatter assembles the
757    /// expected fields without panicking. We don't snapshot stdout
758    /// (printing is fine; what matters is the data we'd print is
759    /// what the spec describes).
760    #[test]
761    fn sagas_diag_fixture_log_has_expected_summary_fields() {
762        let log = LauncherSagaLog::open_in_memory().unwrap();
763
764        // Saga 1 — completed cleanly.
765        log.start_saga(
766            1,
767            "window_cleanup_cascade",
768            &serde_json::json!({"label": "win-1"}),
769        )
770        .unwrap();
771        log.start_step(
772            1,
773            0,
774            "issue_cmd_host_reap_panes",
775            PipeTarget::Host,
776            &Command::Ping { nonce: 1 },
777        )
778        .unwrap();
779        log.finish_step(1, 0, &Event::Pong { nonce: 1, version: 1 })
780            .unwrap();
781        log.terminate_saga(1, crate::saga::log::SagaOutcome::Completed)
782            .unwrap();
783
784        // Saga 2 — recovered (failed_compensation), with a pending
785        // step row to demonstrate the "[step N was in-flight ..]" line.
786        log.start_saga(
787            2,
788            "window_cleanup_cascade",
789            &serde_json::json!({"label": "win-3"}),
790        )
791        .unwrap();
792        log.start_step(
793            2,
794            0,
795            "issue_cmd_host_reap_panes",
796            PipeTarget::Host,
797            &Command::Ping { nonce: 2 },
798        )
799        .unwrap();
800        log.finish_step(2, 0, &Event::Pong { nonce: 2, version: 1 })
801            .unwrap();
802        log.start_step(
803            2,
804            1,
805            "issue_cmd_host_drain_pool",
806            PipeTarget::Host,
807            &Command::Ping { nonce: 3 },
808        )
809        .unwrap();
810        // Step 1 stays pending — saga 2 was in-flight at "crash" time.
811        // Then run the recovery walker manually.
812        log.mark_failed_compensation(2, "launcher restarted while saga in state 'running'")
813            .unwrap();
814
815        let snapshot = log.snapshot_recent(50).unwrap();
816        assert_eq!(snapshot.len(), 2);
817
818        // snapshot_recent returns most-recent-first.
819        let s2 = &snapshot[0];
820        let s1 = &snapshot[1];
821        assert_eq!(s1.saga_id, 1);
822        assert_eq!(s1.state, "completed");
823        assert_eq!(s1.step_count, 1);
824
825        assert_eq!(s2.saga_id, 2);
826        assert_eq!(s2.state, "failed_compensation");
827        // step_count counts succeeded+compensated, so 1 succeeded
828        // (the reap-panes step). The pending drain-pool step is NOT
829        // counted as progress.
830        assert_eq!(s2.step_count, 1);
831        assert!(s2
832            .failure_reason
833            .as_deref()
834            .unwrap_or("")
835            .contains("launcher restarted"));
836
837        // Step rows for saga 2 still surface via unresolved_sagas? No —
838        // saga 2 is in failed_compensation, which is terminal, so
839        // unresolved_sagas EXCLUDES it. The formatter handles this by
840        // simply skipping the step list for terminal-recovered sagas.
841        // That's fine: operators looking for in-flight detail get it
842        // for sagas STILL unresolved; for already-recovered ones, the
843        // failure_reason header already names the prior state.
844        let unresolved = log.unresolved_sagas().unwrap();
845        assert!(unresolved.iter().all(|u| u.saga_id != 2));
846    }
847}