Skip to main content

mur_common/
fleet.rs

1//! Fleet — a named squad of agents working a shared goal over one channel.
2
3use serde::{Deserialize, Serialize};
4
5use crate::deps::ProgramDep;
6use crate::parallel::ParallelConfig;
7
8pub const CONCIERGE_AGENT: &str = "mur";
9
10/// Env var carrying the run id a caller (e.g. the `fleet_run` MCP tool) mints
11/// for a fleet-loop invocation. When set, the loop uses this value as
12/// `RunProgress.run_id` instead of minting its own, so a poller handed this
13/// id back from `fleet_run` can find the same progress file the loop writes.
14pub const RUN_ID_ENV: &str = "MUR_RUN_ID";
15
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17pub struct Fleet {
18    pub name: String,
19    #[serde(default)]
20    pub display_name: String,
21    #[serde(default)]
22    pub goal: String,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub router: Option<String>,
25    /// Team identifier for this fleet; set when the fleet is affiliated with a
26    /// MUR Server team. The fleet runner sets MUR_ACTIVE_TEAM from this value
27    /// before each member turn so team-scoped skills inject correctly.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub team_id: Option<String>,
30    #[serde(default)]
31    pub members: Vec<String>,
32    pub channel_id: String,
33    #[serde(default, skip_serializing_if = "Vec::is_empty")]
34    pub rules: Vec<String>,
35    #[serde(default, skip_serializing_if = "Vec::is_empty")]
36    pub skills: Vec<String>,
37    #[serde(default, rename = "loop", skip_serializing_if = "Option::is_none")]
38    pub loop_cfg: Option<FleetLoop>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub parallel: Option<ParallelConfig>,
41    /// How this fleet handles risk-tiered approvals. Absent → auto: defer when
42    /// nothing can answer (no TTY), wait when a terminal is attached.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub hitl: Option<FleetHitl>,
45    /// External programs this artifact needs at runtime (portable-deps spec).
46    /// Absent → empty; resolved by `mur agent/fleet doctor` + `install-deps`.
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    pub requires_programs: Vec<ProgramDep>,
49    /// Execution limits for runs of this fleet (spec 2026-09-12 §3.1). Absent
50    /// → inherit from `config.yaml`; see `limits_or_legacy` for the read of
51    /// pre-`limits:` files.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub limits: Option<crate::limits::Limits>,
54    /// Tools every member needs for this fleet's work, e.g.
55    /// `[write_file, edit_file, bash]` for a coding fleet. Checked by each
56    /// delegate BEFORE its first model call (spec 2026-09-12 §3.8): a member
57    /// whose policy lacks one fails at dispatch with the grant command, not
58    /// after burning its budget. Empty = no preflight.
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub needs: Vec<String>,
61}
62
63/// Per-fleet approval policy. A floor, never a grant: every field here can only
64/// make an outcome stricter or change WHO waits — none of them approves
65/// anything, and there is deliberately no "auto-approve" knob (that is what
66/// `--yes` is, and it stays unreachable from unattended fleet paths).
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68pub struct FleetHitl {
69    /// What an unanswered Ask-tier gate does. Absent → auto: defer when no TTY
70    /// is attached, wait when one is. Set it explicitly when the TTY is a bad
71    /// proxy for "somebody is watching" — a monitored ops fleet wants `wait`
72    /// even headless; a fleet that must never reach for a human wants `deny`.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub mode: Option<crate::hitl::Unanswered>,
75    /// Risk tiers this fleet's owner has taken standing responsibility for:
76    /// the gate approves them without asking, and records the auto-approval on
77    /// the channel so the run is still auditable.
78    ///
79    /// An explicit LIST, not a ceiling — `RiskTier`'s ordering would make
80    /// `up_to: spend` quietly cover `network-egress` too, and a grant nobody
81    /// meant to write is the whole failure mode this feature has to avoid.
82    /// [`crate::hitl::tier_may_be_granted`] bounds what may appear here.
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub auto_approve_tiers: Vec<crate::hitl::RiskTier>,
85}
86
87impl FleetHitl {
88    /// Reject a policy that grants more than config is allowed to grant.
89    ///
90    /// Loud at load time rather than silently ignored at the gate: a user who
91    /// wrote `destructive` here believes it took effect, and the gap between
92    /// that belief and the truth is where the damage lives.
93    pub fn validate(&self) -> Result<(), String> {
94        let bad: Vec<String> = self
95            .auto_approve_tiers
96            .iter()
97            .filter(|t| !crate::hitl::tier_may_be_granted(**t))
98            .map(|t| format!("{t:?}").to_lowercase())
99            .collect();
100        if bad.is_empty() {
101            return Ok(());
102        }
103        Err(format!(
104            "hitl.auto_approve_tiers may not include {} — standing approval is capped at `write`. \
105             Those actions have to be approved per-run (`mur channel approve`), because their cost \
106             cannot be undone by noticing afterwards.",
107            bad.join(", ")
108        ))
109    }
110}
111
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
113pub struct FleetLoop {
114    #[serde(default = "default_trigger")]
115    pub trigger: String,
116    // default 0 → resolvers fall back (cap → DEFAULT_MAX_ITERATIONS, budget → no cap),
117    // so a minimal `loop:` block (e.g. just `trigger:`) deserializes.
118    #[serde(default)]
119    pub max_iterations: u32,
120    #[serde(default)]
121    pub budget_usd: f64,
122    #[serde(default)]
123    pub deadline: String,
124    #[serde(default)]
125    pub done_when: String,
126}
127
128fn default_trigger() -> String {
129    "manual".to_string()
130}
131
132impl Fleet {
133    /// The fleet's `limits:` block, or one derived from the legacy
134    /// `loop.deadline` / `loop.budget_usd` when the block is absent (§6). An
135    /// explicit block — even an empty one — is authoritative; legacy zero /
136    /// empty values are absence, not a value.
137    pub fn limits_or_legacy(&self) -> Option<crate::limits::Limits> {
138        if let Some(l) = &self.limits {
139            return Some(l.clone());
140        }
141        let lc = self.loop_cfg.as_ref()?;
142        let deadline = (!lc.deadline.trim().is_empty()).then(|| lc.deadline.trim().to_string());
143        let cost_usd = (lc.budget_usd > 0.0).then_some(lc.budget_usd);
144        if deadline.is_none() && cost_usd.is_none() {
145            return None;
146        }
147        Some(crate::limits::Limits {
148            deadline,
149            stuck: None,
150            cost_usd,
151        })
152    }
153
154    pub fn router_or_concierge(&self) -> &str {
155        self.router.as_deref().unwrap_or(CONCIERGE_AGENT)
156    }
157}
158
159/// A fleet name must be a filesystem-safe lowercase slug (it becomes a directory
160/// `~/.mur/fleets/<name>` and a channel id `fleet-<name>`).
161pub fn valid_fleet_name(name: &str) -> bool {
162    !name.is_empty()
163        && name.len() <= 64
164        && name
165            .chars()
166            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
167}
168
169/// Channel-id prefix for a fleet's shared channel (`fleet-<name>`).
170pub const CHANNEL_PREFIX: &str = "fleet-";
171
172/// Derive the fleet name from a channel id of the form `fleet-<name>`.
173///
174/// Returns `None` for non-fleet channels, and also for a `fleet-`-prefixed id
175/// whose remainder isn't a valid fleet name — so a crafted channel id can't
176/// smuggle a path-traversal segment or otherwise masquerade as a fleet to pull
177/// in fleet-scoped skills.
178pub fn fleet_name_from_channel_id(channel_id: &str) -> Option<&str> {
179    let name = channel_id.strip_prefix(CHANNEL_PREFIX)?;
180    valid_fleet_name(name).then_some(name)
181}
182
183/// Job status lifecycle: queued → running → {done, failed, canceled}, with
184/// `blocked` as a non-terminal detour: the run reached an action that needs a
185/// human and stopped there. An approval resumes it, so it is deliberately NOT
186/// terminal — reporting it as done would claim work nobody did, and reporting
187/// it as failed would claim a fault that did not occur.
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(rename_all = "lowercase")]
190pub enum JobStatus {
191    Queued,
192    Running,
193    Blocked,
194    Done,
195    Failed,
196    Canceled,
197}
198
199impl JobStatus {
200    /// Returns true if the job has reached a terminal state.
201    pub fn is_terminal(&self) -> bool {
202        matches!(
203            self,
204            JobStatus::Done | JobStatus::Failed | JobStatus::Canceled
205        )
206    }
207
208    /// Lowercase name — matches the serde representation and the A2A `TaskState`
209    /// mapping. Use this for display instead of `{:?}`/`Debug`, which is not a
210    /// stable display contract.
211    pub fn as_str(&self) -> &'static str {
212        match self {
213            JobStatus::Queued => "queued",
214            JobStatus::Running => "running",
215            JobStatus::Blocked => "blocked",
216            JobStatus::Done => "done",
217            JobStatus::Failed => "failed",
218            JobStatus::Canceled => "canceled",
219        }
220    }
221}
222
223impl std::fmt::Display for JobStatus {
224    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225        f.write_str(self.as_str())
226    }
227}
228
229/// A job represents a unit of work submitted to a fleet for execution.
230/// The `id` is a UUIDv7 — time-sortable, so FIFO ordering is just a filename sort.
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232pub struct Job {
233    pub id: String,
234    pub text: String,
235    /// "cli" | "a2a:<agent-id>" (a2a follow-on).
236    pub source: String,
237    pub status: JobStatus,
238    /// RFC3339 timestamps.
239    pub created_at: String,
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub started_at: Option<String>,
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub finished_at: Option<String>,
244    /// Channel run executed job (results live there).
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub run_id: Option<String>,
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub result: Option<String>,
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub error: Option<String>,
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn env_name_is_stable() {
259        // The literal is the contract other crates (mur-core, mur-agent-runtime)
260        // pass this same value across process boundaries — pin it against
261        // accidental rename.
262        assert_eq!(RUN_ID_ENV, "MUR_RUN_ID");
263    }
264
265    #[test]
266    fn valid_fleet_name_accepts_and_rejects() {
267        // accepted
268        assert!(valid_fleet_name("dev"));
269        assert!(valid_fleet_name("dev-team"));
270        assert!(valid_fleet_name("dev_1"));
271        assert!(valid_fleet_name("ab12"));
272        // rejected
273        assert!(!valid_fleet_name("")); // empty
274        assert!(!valid_fleet_name("../x")); // path traversal
275        assert!(!valid_fleet_name("a/b")); // slash
276        assert!(!valid_fleet_name("a\\b")); // backslash
277        assert!(!valid_fleet_name("Dev")); // uppercase
278        assert!(!valid_fleet_name("a b")); // space
279        assert!(!valid_fleet_name(".hidden")); // dot
280    }
281
282    #[test]
283    fn fleet_name_from_channel_id_extracts_and_validates() {
284        // valid fleet channels
285        assert_eq!(fleet_name_from_channel_id("fleet-dev"), Some("dev"));
286        assert_eq!(
287            fleet_name_from_channel_id("fleet-my-squad"),
288            Some("my-squad")
289        );
290        assert_eq!(fleet_name_from_channel_id("fleet-ab12"), Some("ab12"));
291        // not a fleet channel
292        assert_eq!(fleet_name_from_channel_id("dev"), None);
293        assert_eq!(fleet_name_from_channel_id("agent:foo:uuid"), None);
294        // prefixed but invalid remainder → rejected (no masquerading)
295        assert_eq!(fleet_name_from_channel_id("fleet-"), None); // empty
296        assert_eq!(fleet_name_from_channel_id("fleet-../etc"), None); // traversal
297        assert_eq!(fleet_name_from_channel_id("fleet-a/b"), None); // slash
298        assert_eq!(fleet_name_from_channel_id("fleet-Dev"), None); // uppercase
299    }
300
301    #[test]
302    fn fleet_minimal_yaml_deserializes_with_defaults() {
303        let f: Fleet = serde_yaml::from_str("name: dev\nchannel_id: fleet-dev\n").unwrap();
304        assert_eq!(f.name, "dev");
305        assert_eq!(f.channel_id, "fleet-dev");
306        assert!(f.members.is_empty());
307        assert_eq!(f.router_or_concierge(), CONCIERGE_AGENT);
308        assert!(f.loop_cfg.is_none());
309    }
310
311    /// `hitl.mode` must survive a round-trip and stay absent when unset — an
312    /// absent policy means "use the TTY-derived default", which is a different
313    /// statement from any of the three explicit modes.
314    #[test]
315    fn fleet_hitl_mode_parses_and_defaults_to_absent() {
316        let bare: Fleet = serde_yaml::from_str("name: dev\nchannel_id: fleet-dev\n").unwrap();
317        assert!(bare.hitl.is_none(), "no policy stated ⇒ auto");
318
319        let declared: Fleet =
320            serde_yaml::from_str("name: dev\nchannel_id: fleet-dev\nhitl:\n  mode: deny\n")
321                .unwrap();
322        assert_eq!(
323            declared.hitl.as_ref().and_then(|h| h.mode),
324            Some(crate::hitl::Unanswered::Deny)
325        );
326
327        for (yaml, want) in [
328            ("defer", crate::hitl::Unanswered::Defer),
329            ("wait", crate::hitl::Unanswered::Wait),
330            ("deny", crate::hitl::Unanswered::Deny),
331        ] {
332            let f: Fleet = serde_yaml::from_str(&format!(
333                "name: dev\nchannel_id: fleet-dev\nhitl:\n  mode: {yaml}\n"
334            ))
335            .unwrap();
336            assert_eq!(f.hitl.and_then(|h| h.mode), Some(want));
337        }
338
339        // An unknown mode is a typo, not a silent fallback to something looser.
340        assert!(
341            serde_yaml::from_str::<Fleet>(
342                "name: dev\nchannel_id: fleet-dev\nhitl:\n  mode: yolo\n"
343            )
344            .is_err()
345        );
346    }
347
348    /// `auto_approve_tiers` parses; `validate` accepts write-and-below and
349    /// refuses everything above — loudly, because a user who wrote
350    /// `destructive` believes it took effect, and the gap between that belief
351    /// and the truth is where the damage lives.
352    #[test]
353    fn auto_approve_tiers_parse_and_validate_against_the_ceiling() {
354        let f: Fleet = serde_yaml::from_str(
355            "name: dev\nchannel_id: fleet-dev\nhitl:\n  auto_approve_tiers: [read, write]\n",
356        )
357        .unwrap();
358        let hitl = f.hitl.as_ref().unwrap();
359        assert_eq!(
360            hitl.auto_approve_tiers,
361            vec![crate::hitl::RiskTier::Read, crate::hitl::RiskTier::Write]
362        );
363        assert!(hitl.validate().is_ok());
364
365        let f: Fleet = serde_yaml::from_str(
366            "name: dev\nchannel_id: fleet-dev\nhitl:\n  auto_approve_tiers: [write, destructive]\n",
367        )
368        .unwrap();
369        let err = f.hitl.as_ref().unwrap().validate().unwrap_err();
370        assert!(err.contains("destructive"), "{err}");
371        assert!(err.contains("write"), "the ceiling must be named: {err}");
372    }
373
374    #[test]
375    fn fleet_yaml_roundtrip_and_router_default() {
376        let f = Fleet {
377            name: "dev".into(),
378            display_name: "Dev Team".into(),
379            goal: "ship it".into(),
380            router: None,
381            team_id: None,
382            members: vec!["pm".into(), "qa".into()],
383            channel_id: "fleet-dev".into(),
384            rules: vec![],
385            skills: vec![],
386            loop_cfg: None,
387            parallel: None,
388            hitl: None,
389            requires_programs: vec![],
390            limits: None,
391            needs: vec![],
392        };
393        assert_eq!(f.router_or_concierge(), CONCIERGE_AGENT);
394        let yaml = serde_yaml::to_string(&f).unwrap();
395        let back: Fleet = serde_yaml::from_str(&yaml).unwrap();
396        assert_eq!(back, f);
397        // `loop:` key (not `loop_cfg`) when present
398        let with_loop: Fleet = serde_yaml::from_str(
399            "name: dev\ndisplay_name: Dev\ngoal: test\nchannel_id: fleet-dev\nrules: []\nskills: []\nmembers: []\nloop:\n  trigger: manual\n  max_iterations: 3\n  budget_usd: 1.0\n  deadline: '2026-12-31'\n  done_when: 'all_tasks_done'\n",
400        ).unwrap();
401        assert_eq!(with_loop.loop_cfg.unwrap().max_iterations, 3);
402    }
403
404    #[test]
405    fn minimal_loop_block_deserializes_with_defaults() {
406        // A `loop:` block with only a trigger must not fail (max_iterations /
407        // budget_usd default to 0 → resolvers fall back).
408        let f: Fleet = serde_yaml::from_str(
409            "name: dev\nchannel_id: fleet-dev\nloop:\n  trigger: \"interval:1h\"\n",
410        )
411        .unwrap();
412        let l = f.loop_cfg.unwrap();
413        assert_eq!(l.trigger, "interval:1h");
414        assert_eq!(l.max_iterations, 0);
415        assert_eq!(l.budget_usd, 0.0);
416    }
417
418    #[test]
419    fn job_status_serde_is_lowercase_and_terminal_predicate() {
420        assert_eq!(
421            serde_yaml::to_string(&JobStatus::Queued).unwrap().trim(),
422            "queued"
423        );
424        assert_eq!(
425            serde_yaml::to_string(&JobStatus::Done).unwrap().trim(),
426            "done"
427        );
428        assert!(!JobStatus::Queued.is_terminal());
429        assert!(!JobStatus::Running.is_terminal());
430        assert!(JobStatus::Done.is_terminal());
431        assert!(JobStatus::Failed.is_terminal());
432        assert!(JobStatus::Canceled.is_terminal());
433    }
434
435    #[test]
436    fn job_status_as_str_and_display_match_serde_for_all_variants() {
437        for s in [
438            JobStatus::Queued,
439            JobStatus::Running,
440            JobStatus::Done,
441            JobStatus::Failed,
442            JobStatus::Canceled,
443        ] {
444            let serde = serde_yaml::to_string(&s).unwrap();
445            assert_eq!(
446                serde.trim(),
447                s.as_str(),
448                "as_str must match serde for {s:?}"
449            );
450            assert_eq!(s.to_string(), s.as_str(), "Display must delegate to as_str");
451        }
452    }
453
454    #[test]
455    fn job_yaml_roundtrip_with_optional_fields_skipped() {
456        let j = Job {
457            id: "0190f3a2-0000-7000-8000-000000000000".into(),
458            text: "ship it".into(),
459            source: "cli".into(),
460            status: JobStatus::Queued,
461            created_at: "2026-06-24T00:00:00Z".into(),
462            started_at: None,
463            finished_at: None,
464            run_id: None,
465            result: None,
466            error: None,
467        };
468        let yaml = serde_yaml::to_string(&j).unwrap();
469        assert!(
470            !yaml.contains("started_at"),
471            "None optionals must be skipped: {yaml}"
472        );
473        let back: Job = serde_yaml::from_str(&yaml).unwrap();
474        assert_eq!(back, j);
475    }
476}
477
478#[cfg(test)]
479mod limits_tests {
480    use super::*;
481
482    fn fleet(loop_cfg: Option<FleetLoop>, limits: Option<crate::limits::Limits>) -> Fleet {
483        Fleet {
484            name: "dev".into(),
485            display_name: String::new(),
486            goal: "g".into(),
487            router: None,
488            team_id: None,
489            members: vec![],
490            channel_id: "fleet-dev".into(),
491            rules: vec![],
492            skills: vec![],
493            loop_cfg,
494            parallel: None,
495            hitl: None,
496            requires_programs: vec![],
497            limits,
498            needs: vec![],
499        }
500    }
501
502    /// §6: a fleet written before `limits:` existed keeps its bound — the old
503    /// loop.deadline / loop.budget_usd are read as limits until it is
504    /// rewritten. An explicit `limits:` block wins outright, even if empty.
505    #[test]
506    fn legacy_loop_fields_are_read_as_limits_only_when_the_block_is_absent() {
507        let lc = FleetLoop {
508            trigger: "manual".into(),
509            max_iterations: 8,
510            budget_usd: 5.0,
511            deadline: "2h".into(),
512            done_when: String::new(),
513        };
514        let legacy = fleet(Some(lc.clone()), None)
515            .limits_or_legacy()
516            .expect("derived");
517        assert_eq!(legacy.deadline.as_deref(), Some("2h"));
518        assert_eq!(legacy.cost_usd, Some(5.0));
519        assert_eq!(legacy.stuck, None, "the old loop had no stuck setting");
520
521        // Zero / empty legacy values are absence, not a value.
522        let zero = FleetLoop {
523            budget_usd: 0.0,
524            deadline: String::new(),
525            ..lc.clone()
526        };
527        assert_eq!(fleet(Some(zero), None).limits_or_legacy(), None);
528
529        // An explicit block, even empty, is authoritative.
530        let explicit = fleet(Some(lc), Some(crate::limits::Limits::default())).limits_or_legacy();
531        assert_eq!(explicit, Some(crate::limits::Limits::default()));
532
533        // Round trip: a fleet without limits serialises without the key.
534        let yaml = serde_yaml_ng::to_string(&fleet(None, None)).unwrap();
535        assert!(!yaml.contains("limits"), "{yaml}");
536    }
537
538    /// `needs:` round-trips and is absent from the YAML when empty — a fleet
539    /// that declares nothing gets no preflight and no new key.
540    #[test]
541    fn needs_round_trip_and_stay_absent_when_empty() {
542        let mut f = fleet(None, None);
543        assert!(!serde_yaml_ng::to_string(&f).unwrap().contains("needs"));
544        f.needs = vec!["write_file".into(), "bash".into()];
545        let yaml = serde_yaml_ng::to_string(&f).unwrap();
546        assert!(yaml.contains("needs:"), "{yaml}");
547        let back: Fleet = serde_yaml_ng::from_str(&yaml).unwrap();
548        assert_eq!(
549            back.needs,
550            vec!["write_file".to_string(), "bash".to_string()]
551        );
552    }
553}