Skip to main content

agentmux_common/
srv_stderr.rs

1//! The stderr line protocol `agentmux-srv` uses to talk to whichever process
2//! supervises it — the launcher in packaged builds and `task dev`
3//! (`crates/launcher/src/srv_spawner.rs`), or the CEF host in
4//! `task dev:standalone` (`crates/cef/src/sidecar.rs`).
5//!
6//! srv formats these lines; both supervisors parse them. Putting the
7//! vocabulary here means one definition instead of three string literals that
8//! have to stay byte-identical by convention (the "keep in sync" pattern
9//! `docs/reports/REPORT_DRY_AND_MODULARITY_AUDIT_2026_09_06.md` §2 counts 334
10//! instances of).
11//!
12//! Every line is single-line and prefix-tagged. In startup order:
13//!
14//! - `AGENTMUXSRV-MIGRATING migrations:<n>` — in-process migrations are about
15//!   to run; supervisors extend their ESTART deadline. (Still emitted and
16//!   parsed with inline literals at the three sites above — not migrated to
17//!   this module yet; only the new line below lives here.)
18//! - `AGENTMUXSRV-MIGRATION-BEGIN id:<id> description:<text>` /
19//!   `AGENTMUXSRV-MIGRATION-END id:<id> duration_ms:<n>` — one pending
20//!   migration is starting / has finished, inside the `AGENTMUXSRV-MIGRATING`
21//!   window above. Before these existed, a slow migration was invisible
22//!   past the initial count — the splash showed a bare, silently-running
23//!   "Backend startup" clock with no indication anything unusual (a
24//!   multi-minute migration) was happening inside it. See
25//!   `docs/reports/REPORT_SPLASH_SCREEN_ARCHITECTURE_RETHINK_2026_09_14.md`
26//!   §2.1. Only the launcher supervisor turns these into splash sub-rows
27//!   (against the already-open `backend` stage — a migration is not a
28//!   separate top-level stage, it happens entirely inside that one); the
29//!   CEF-host sidecar has no splash to report to and simply logs them like
30//!   any other stderr line, same as it already did for the unparsed line
31//!   before this existed.
32//! - `AGENTMUXSRV-MIGRATION-FAILED error:<text>` — a migration failed and srv
33//!   is about to exit 1 **without** emitting ESTART. Added by Phase 1 of
34//!   `docs/specs/SPEC_MIGRATION_SYSTEM_HARDENING_2026_08_03.md`; before it,
35//!   a failed migration was a `warn!` and srv booted anyway against a store in
36//!   an unknown state.
37//! - `AGENTMUXSRV-ESTART ...` — ready. Parsed separately by each supervisor;
38//!   unchanged.
39
40/// Prefix of the line srv writes to stderr immediately before exiting 1
41/// because `run_pending_migrations` failed. Supervisors match on this to fail
42/// fast with the real reason instead of waiting out the ESTART timeout.
43pub const MIGRATION_FAILED_PREFIX: &str = "AGENTMUXSRV-MIGRATION-FAILED";
44
45/// Format the failure line srv emits. The error text is flattened to a single
46/// line (the protocol is line-delimited; a multi-line SQLite error would
47/// otherwise split into one tagged line and several untagged ones).
48pub fn migration_failed_line(err: &str) -> String {
49    format!("{} error:{}", MIGRATION_FAILED_PREFIX, one_line(err))
50}
51
52/// Parse a stderr line. Returns the error message if this is a
53/// `MIGRATION_FAILED_PREFIX` line, `None` for any other line. Tolerates a
54/// missing `error:` field and never returns an empty message, so callers can
55/// put the result straight into a user-facing dialog.
56pub fn parse_migration_failed_line(line: &str) -> Option<String> {
57    let rest = line.strip_prefix(MIGRATION_FAILED_PREFIX)?;
58    let rest = rest.trim_start();
59    let msg = rest.strip_prefix("error:").unwrap_or(rest).trim();
60    Some(if msg.is_empty() { "unknown error".to_string() } else { msg.to_string() })
61}
62
63fn one_line(s: &str) -> String {
64    s.split(['\n', '\r'])
65        .map(str::trim)
66        .filter(|part| !part.is_empty())
67        .collect::<Vec<_>>()
68        .join(" | ")
69}
70
71/// Prefix of the line srv writes to stderr just before applying one pending
72/// migration.
73pub const MIGRATION_BEGIN_PREFIX: &str = "AGENTMUXSRV-MIGRATION-BEGIN";
74/// Prefix of the line srv writes to stderr just after one migration applies
75/// successfully. (No `-END` line is written on failure — `up()` returning
76/// `Err` goes straight to `AGENTMUXSRV-MIGRATION-FAILED` and srv exits.)
77pub const MIGRATION_END_PREFIX: &str = "AGENTMUXSRV-MIGRATION-END";
78
79/// A parsed `MIGRATION_BEGIN_PREFIX` line.
80pub struct MigrationBegin {
81    pub id: String,
82    pub description: String,
83}
84
85/// A parsed `MIGRATION_END_PREFIX` line.
86pub struct MigrationEnd {
87    pub id: String,
88    pub duration_ms: u64,
89}
90
91/// Migration ids (`m.id()`, e.g. `"0020_agent_color_backfill"`) are a fixed,
92/// engineer-chosen identifier format with no spaces — never free text — so
93/// splitting `id:<id> description:<text>` on the first space is safe and
94/// exact, unlike `description` itself which is flattened via `one_line`.
95pub fn migration_begin_line(id: &str, description: &str) -> String {
96    format!("{} id:{} description:{}", MIGRATION_BEGIN_PREFIX, id, one_line(description))
97}
98
99pub fn migration_end_line(id: &str, duration_ms: u64) -> String {
100    format!("{} id:{} duration_ms:{}", MIGRATION_END_PREFIX, id, duration_ms)
101}
102
103/// Parse a `MIGRATION_BEGIN_PREFIX` line. `None` for any other line, or a
104/// begin line missing the `id:` field it cannot be meaningfully split
105/// without.
106pub fn parse_migration_begin_line(line: &str) -> Option<MigrationBegin> {
107    let rest = line.strip_prefix(MIGRATION_BEGIN_PREFIX)?.trim_start();
108    let rest = rest.strip_prefix("id:")?;
109    let (id, rest) = rest.split_once(' ')?;
110    let description = rest.strip_prefix("description:").unwrap_or(rest).trim();
111    Some(MigrationBegin { id: id.to_string(), description: description.to_string() })
112}
113
114/// Parse a `MIGRATION_END_PREFIX` line. `None` for any other line, a
115/// malformed one, or a non-numeric/missing `duration_ms:` field.
116pub fn parse_migration_end_line(line: &str) -> Option<MigrationEnd> {
117    let rest = line.strip_prefix(MIGRATION_END_PREFIX)?.trim_start();
118    let rest = rest.strip_prefix("id:")?;
119    let (id, rest) = rest.split_once(' ')?;
120    let ms_str = rest.trim().strip_prefix("duration_ms:")?;
121    let duration_ms = ms_str.trim().parse::<u64>().ok()?;
122    Some(MigrationEnd { id: id.to_string(), duration_ms })
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn line_is_prefixed_and_single_line() {
131        let line = migration_failed_line("migration 0007 failed:\r\n  FOREIGN KEY constraint failed\n");
132        assert_eq!(
133            line,
134            "AGENTMUXSRV-MIGRATION-FAILED error:migration 0007 failed: | FOREIGN KEY constraint failed"
135        );
136        assert!(!line.contains('\n'));
137    }
138
139    #[test]
140    fn round_trips_through_parse() {
141        let msg = "run_pending_migrations: open shared store: disk I/O error";
142        assert_eq!(parse_migration_failed_line(&migration_failed_line(msg)).as_deref(), Some(msg));
143    }
144
145    #[test]
146    fn parse_tolerates_missing_error_field() {
147        assert_eq!(
148            parse_migration_failed_line("AGENTMUXSRV-MIGRATION-FAILED something broke").as_deref(),
149            Some("something broke")
150        );
151    }
152
153    #[test]
154    fn parse_never_returns_empty_message() {
155        assert_eq!(parse_migration_failed_line("AGENTMUXSRV-MIGRATION-FAILED").as_deref(), Some("unknown error"));
156        assert_eq!(parse_migration_failed_line("AGENTMUXSRV-MIGRATION-FAILED error:").as_deref(), Some("unknown error"));
157    }
158
159    #[test]
160    fn parse_ignores_other_protocol_lines() {
161        assert_eq!(parse_migration_failed_line("AGENTMUXSRV-MIGRATING migrations:3"), None);
162        assert_eq!(parse_migration_failed_line("AGENTMUXSRV-ESTART ws:127.0.0.1:1 web:127.0.0.1:2"), None);
163        assert_eq!(parse_migration_failed_line("plain stderr noise"), None);
164    }
165
166    #[test]
167    fn migrating_prefix_is_not_a_prefix_of_failed() {
168        // Both start with "AGENTMUXSRV-MIGRATI"; the supervisors check
169        // `starts_with` for each, so neither may be a prefix of the other.
170        assert!(!MIGRATION_FAILED_PREFIX.starts_with("AGENTMUXSRV-MIGRATING"));
171        assert!(!"AGENTMUXSRV-MIGRATING".starts_with(MIGRATION_FAILED_PREFIX));
172    }
173
174    #[test]
175    fn none_of_the_four_migration_prefixes_are_a_prefix_of_another() {
176        // All four share "AGENTMUXSRV-MIGRAT"; every supervisor matches with
177        // `starts_with`/`strip_prefix`, so a false-positive prefix match
178        // would misroute a real line.
179        let all = [
180            "AGENTMUXSRV-MIGRATING",
181            MIGRATION_FAILED_PREFIX,
182            MIGRATION_BEGIN_PREFIX,
183            MIGRATION_END_PREFIX,
184        ];
185        for a in all {
186            for b in all {
187                if a == b { continue; }
188                assert!(!a.starts_with(b), "{a:?} must not start with {b:?}");
189            }
190        }
191    }
192
193    #[test]
194    fn begin_line_round_trips_through_parse() {
195        let line = migration_begin_line("0020_agent_color_backfill", "backfill default agent colors");
196        let parsed = parse_migration_begin_line(&line).expect("must parse");
197        assert_eq!(parsed.id, "0020_agent_color_backfill");
198        assert_eq!(parsed.description, "backfill default agent colors");
199    }
200
201    #[test]
202    fn begin_line_flattens_a_multiline_description() {
203        let line = migration_begin_line("0007_x", "line one\nline two");
204        assert!(!line.contains('\n'));
205        assert_eq!(parse_migration_begin_line(&line).unwrap().description, "line one | line two");
206    }
207
208    #[test]
209    fn end_line_round_trips_through_parse() {
210        let line = migration_end_line("0020_agent_color_backfill", 147);
211        let parsed = parse_migration_end_line(&line).expect("must parse");
212        assert_eq!(parsed.id, "0020_agent_color_backfill");
213        assert_eq!(parsed.duration_ms, 147);
214    }
215
216    #[test]
217    fn begin_and_end_parsers_ignore_other_protocol_lines_and_each_other() {
218        assert!(parse_migration_begin_line("AGENTMUXSRV-MIGRATING migrations:3").is_none());
219        assert!(parse_migration_begin_line("AGENTMUXSRV-ESTART ws:x web:y").is_none());
220        assert!(parse_migration_begin_line(&migration_end_line("0007_x", 5)).is_none());
221        assert!(parse_migration_end_line(&migration_begin_line("0007_x", "d")).is_none());
222    }
223
224    #[test]
225    fn malformed_begin_and_end_lines_do_not_panic() {
226        assert!(parse_migration_begin_line(MIGRATION_BEGIN_PREFIX).is_none());
227        assert!(parse_migration_begin_line(&format!("{MIGRATION_BEGIN_PREFIX} garbage")).is_none());
228        assert!(parse_migration_end_line(MIGRATION_END_PREFIX).is_none());
229        assert!(parse_migration_end_line(&format!("{MIGRATION_END_PREFIX} id:0007_x duration_ms:notanumber")).is_none());
230    }
231}