meerkat-mob 0.5.2

Multi-agent orchestration runtime for Meerkat
Documentation
use crate::roster::MobMemberKickoffPhase;
use meerkat_runtime::completion::CompletionOutcome;

#[derive(Debug)]
pub enum MobMemberBootstrapInput {
    MarkPending,
    MarkStarting,
    ResolveOutcome { outcome: CompletionOutcome },
    CancelRequested,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MobMemberBootstrapEffect {
    PersistKickoff {
        phase: MobMemberKickoffPhase,
        error: Option<String>,
    },
    EmitLifecycleNotice {
        intent: &'static str,
    },
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MobMemberBootstrapTransition {
    pub from_phase: Option<MobMemberKickoffPhase>,
    pub next_phase: MobMemberKickoffPhase,
    pub effects: Vec<MobMemberBootstrapEffect>,
}

#[derive(Debug, Clone, thiserror::Error)]
#[error("illegal member bootstrap transition: {input} in phase {phase:?}")]
pub struct MobMemberBootstrapError {
    phase: Option<MobMemberKickoffPhase>,
    input: &'static str,
}

#[derive(Debug, Clone)]
pub struct MobMemberBootstrapAuthority {
    phase: Option<MobMemberKickoffPhase>,
}

impl MobMemberBootstrapAuthority {
    pub fn new(phase: Option<MobMemberKickoffPhase>) -> Self {
        Self { phase }
    }

    pub fn apply(
        &mut self,
        input: MobMemberBootstrapInput,
    ) -> Result<MobMemberBootstrapTransition, MobMemberBootstrapError> {
        let from_phase = self.phase;
        let (next_phase, effects) = evaluate_transition(from_phase, &input)?;
        self.phase = Some(next_phase);
        Ok(MobMemberBootstrapTransition {
            from_phase,
            next_phase,
            effects,
        })
    }
}

fn evaluate_transition(
    phase: Option<MobMemberKickoffPhase>,
    input: &MobMemberBootstrapInput,
) -> Result<(MobMemberKickoffPhase, Vec<MobMemberBootstrapEffect>), MobMemberBootstrapError> {
    use MobMemberBootstrapEffect::{EmitLifecycleNotice, PersistKickoff};
    use MobMemberBootstrapInput::{CancelRequested, MarkPending, MarkStarting, ResolveOutcome};
    use MobMemberKickoffPhase::{CallbackPending, Cancelled, Failed, Pending, Started, Starting};

    let reject = |input: &'static str| Err(MobMemberBootstrapError { phase, input });

    match (phase, input) {
        (None, MarkPending) => Ok((
            Pending,
            vec![PersistKickoff {
                phase: Pending,
                error: None,
            }],
        )),
        (Some(Pending), MarkStarting) => Ok((
            Starting,
            vec![PersistKickoff {
                phase: Starting,
                error: None,
            }],
        )),
        (
            Some(Starting | CallbackPending),
            ResolveOutcome {
                outcome: CompletionOutcome::Completed(_) | CompletionOutcome::CompletedWithoutResult,
            },
        ) => Ok((
            Started,
            vec![PersistKickoff {
                phase: Started,
                error: None,
            }],
        )),
        (
            Some(Starting | CallbackPending),
            ResolveOutcome {
                outcome: CompletionOutcome::CallbackPending { .. },
            },
        ) => Ok((
            CallbackPending,
            vec![PersistKickoff {
                phase: CallbackPending,
                error: None,
            }],
        )),
        (
            Some(Starting | CallbackPending),
            ResolveOutcome {
                outcome:
                    CompletionOutcome::Abandoned(reason) | CompletionOutcome::RuntimeTerminated(reason),
            },
        ) => Ok((
            Failed,
            vec![
                PersistKickoff {
                    phase: Failed,
                    error: Some(reason.clone()),
                },
                EmitLifecycleNotice {
                    intent: "mob.kickoff_failed",
                },
            ],
        )),
        (Some(Pending | Starting | CallbackPending), CancelRequested) => Ok((
            Cancelled,
            vec![
                PersistKickoff {
                    phase: Cancelled,
                    error: None,
                },
                EmitLifecycleNotice {
                    intent: "mob.kickoff_cancelled",
                },
            ],
        )),
        (_, MarkPending) => reject("MarkPending"),
        (_, MarkStarting) => reject("MarkStarting"),
        (_, ResolveOutcome { .. }) => reject("ResolveOutcome"),
        (_, CancelRequested) => reject("CancelRequested"),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn pending_then_starting_is_legal() {
        let mut auth = MobMemberBootstrapAuthority::new(None);
        let pending = auth.apply(MobMemberBootstrapInput::MarkPending).unwrap();
        assert_eq!(pending.next_phase, MobMemberKickoffPhase::Pending);

        let starting = auth.apply(MobMemberBootstrapInput::MarkStarting).unwrap();
        assert_eq!(starting.next_phase, MobMemberKickoffPhase::Starting);
    }

    #[test]
    fn abandoned_becomes_failed_and_emits_notice() {
        let mut auth = MobMemberBootstrapAuthority::new(Some(MobMemberKickoffPhase::Starting));
        let transition = auth
            .apply(MobMemberBootstrapInput::ResolveOutcome {
                outcome: CompletionOutcome::Abandoned("provider overloaded".into()),
            })
            .unwrap();
        assert_eq!(transition.next_phase, MobMemberKickoffPhase::Failed);
        assert!(transition.effects.iter().any(|effect| matches!(
            effect,
            MobMemberBootstrapEffect::EmitLifecycleNotice { intent }
                if *intent == "mob.kickoff_failed"
        )));
    }

    #[test]
    fn callback_pending_can_later_start() {
        let mut auth = MobMemberBootstrapAuthority::new(Some(MobMemberKickoffPhase::Starting));
        let pending = auth
            .apply(MobMemberBootstrapInput::ResolveOutcome {
                outcome: CompletionOutcome::CallbackPending {
                    tool_name: "browser".into(),
                    args: serde_json::json!({"url":"https://example.com"}),
                },
            })
            .unwrap();
        assert_eq!(pending.next_phase, MobMemberKickoffPhase::CallbackPending);

        let started = auth
            .apply(MobMemberBootstrapInput::ResolveOutcome {
                outcome: CompletionOutcome::CompletedWithoutResult,
            })
            .unwrap();
        assert_eq!(started.next_phase, MobMemberKickoffPhase::Started);
    }

    #[test]
    fn cancel_only_allowed_during_active_bootstrap() {
        let mut auth = MobMemberBootstrapAuthority::new(Some(MobMemberKickoffPhase::Started));
        let err = auth
            .apply(MobMemberBootstrapInput::CancelRequested)
            .expect_err("started kickoff must not cancel");
        assert_eq!(err.phase, Some(MobMemberKickoffPhase::Started));
    }
}