use meerkat_mob::launch::MemberLaunchMode;
use meerkat_mob::{ForkMemberResult, MobError, SpawnMemberSpec};
use serde::Serialize;
use crate::unified_runtime::{ErrorEvent, UnifiedRuntime};
#[derive(Debug, Clone, Serialize)]
pub struct ForkMemberOutcome {
pub source_member_alias: String,
pub member_alias: String,
#[serde(flatten)]
pub result: ForkMemberResult,
}
#[derive(Debug)]
pub enum ForkMemberError {
InvalidRequest(String),
SourceAuthority(String),
Mob(MobError),
}
impl std::fmt::Display for ForkMemberError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidRequest(message) => write!(f, "{message}"),
Self::SourceAuthority(message) => {
write!(f, "fork source alias authority unavailable: {message}")
}
Self::Mob(error) => write!(f, "{error}"),
}
}
}
impl std::error::Error for ForkMemberError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Mob(error) => Some(error),
Self::InvalidRequest(_) | Self::SourceAuthority(_) => None,
}
}
}
impl From<MobError> for ForkMemberError {
fn from(value: MobError) -> Self {
Self::Mob(value)
}
}
impl ForkMemberError {
pub fn structured_data(&self) -> Option<serde_json::Value> {
match self {
Self::Mob(error) => error.structured_data(),
Self::InvalidRequest(_) | Self::SourceAuthority(_) => None,
}
}
pub fn committed_fork_is_recoverable(&self) -> bool {
matches!(self, Self::Mob(MobError::ForkMemberProvisionFailed { .. }))
}
}
async fn reserve_and_fork(
identity_runtime: Option<std::sync::Arc<crate::identity_first::IdentityRuntime>>,
handle: meerkat_mob::MobHandle,
source_identity: meerkat_mob::ids::AgentIdentity,
mut spec: SpawnMemberSpec,
child_alias: String,
message_count: Option<usize>,
) -> Result<(String, Result<ForkMemberResult, MobError>), String> {
let raw_reservation = crate::member_comms_id::reserve_raw_member_target(
identity_runtime.as_ref(),
child_alias.as_str(),
)
.await?;
let alias = raw_reservation.alias().to_string();
spec.identity = crate::member_comms_id::mob_member_id(alias.as_str());
let fork = Box::pin(handle.fork_member(&source_identity, spec, message_count)).await;
drop(raw_reservation);
Ok((alias, fork))
}
fn validate_fork_spec(spec: &SpawnMemberSpec) -> Result<(), ForkMemberError> {
if let Some(labels) = spec.labels.as_ref() {
crate::member_comms_id::validate_raw_identity_labels(labels)
.map_err(|message| ForkMemberError::InvalidRequest(message.to_string()))?;
}
if !matches!(spec.launch_mode, MemberLaunchMode::Fresh) {
return Err(ForkMemberError::InvalidRequest(
"durable fork owns the child's launch mode; leave \
SpawnMemberSpec::launch_mode at Fresh (MemberLaunchMode::Fork is the \
prompt-context helper fork, not a transcript fork)"
.to_string(),
));
}
if spec.identity.as_str().trim().is_empty() {
return Err(ForkMemberError::InvalidRequest(
"fork child identity must not be empty".to_string(),
));
}
Ok(())
}
impl UnifiedRuntime {
pub async fn fork_member(
&self,
source_member_alias: &str,
spec: SpawnMemberSpec,
message_count: Option<usize>,
) -> Result<ForkMemberOutcome, ForkMemberError> {
self.fork_member_with_identity_runtime(None, source_member_alias, spec, message_count)
.await
}
pub async fn fork_member_with_identity_runtime(
&self,
identity_runtime: Option<&std::sync::Arc<crate::identity_first::IdentityRuntime>>,
source_member_alias: &str,
spec: SpawnMemberSpec,
message_count: Option<usize>,
) -> Result<ForkMemberOutcome, ForkMemberError> {
let identity_runtime = identity_runtime.or_else(|| self.identity_runtime());
validate_fork_spec(&spec)?;
let source_alias =
crate::member_comms_id::runtime_alias_str(source_member_alias).into_owned();
if source_alias.trim().is_empty() {
return Err(ForkMemberError::InvalidRequest(
"fork source member alias must not be empty".to_string(),
));
}
let source_identity = crate::member_comms_id::mob_member_id(source_alias.as_str());
let requested_child_alias = spec.identity.as_str().to_string();
let profile = spec.role_name.to_string();
let handle = self.mob_handle();
let identity_runtime_owned = identity_runtime.cloned();
crate::member_comms_id::validate_raw_member_target(
identity_runtime,
requested_child_alias.as_str(),
)
.await
.map_err(ForkMemberError::InvalidRequest)?;
let source_target = match identity_runtime {
Some(identity_runtime) => identity_runtime
.member_alias_lifecycle_target(&source_alias)
.await
.map_err(|error| ForkMemberError::SourceAuthority(error.to_string()))?,
None => None,
};
let (member_alias, fork) = if let Some(source_target) = source_target {
crate::identity_first::IdentityRuntime::run_member_alias_targets_operation_tracked(
vec![source_target],
move || {
reserve_and_fork(
identity_runtime_owned,
handle,
source_identity,
spec,
requested_child_alias,
message_count,
)
},
)
.await
.map_err(|error| ForkMemberError::SourceAuthority(error.to_string()))?
} else {
reserve_and_fork(
identity_runtime_owned,
handle,
source_identity,
spec,
requested_child_alias,
message_count,
)
.await
.map_err(ForkMemberError::InvalidRequest)?
};
match fork {
Ok(result) => Ok(ForkMemberOutcome {
source_member_alias: source_alias,
member_alias,
result,
}),
Err(error) => {
self.fire_error(ErrorEvent::SpawnFailure {
member_id: member_alias,
profile,
error: format!("{error}"),
});
Err(ForkMemberError::Mob(error))
}
}
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use meerkat_mob::error::ForkSourceUnavailableCause;
use meerkat_mob::ids::AgentIdentity;
fn spec(alias: &str) -> SpawnMemberSpec {
SpawnMemberSpec::new(
meerkat_mob::ProfileName::from("general"),
AgentIdentity::from(alias),
)
}
#[test]
fn a_caller_supplied_launch_mode_is_refused_not_silently_overwritten() {
let mut resume = spec("child");
resume.launch_mode = MemberLaunchMode::Resume {
resume_from_role: None,
bridge_session_id: meerkat_core::types::SessionId::new(),
};
let error = validate_fork_spec(&resume).expect_err("resume mode must be refused");
assert!(matches!(error, ForkMemberError::InvalidRequest(_)));
let mut prompt_fork = spec("child");
prompt_fork.launch_mode = MemberLaunchMode::Fork {
source_member_id: AgentIdentity::from("source"),
fork_context: meerkat_mob::launch::ForkContext::default(),
};
let error = validate_fork_spec(&prompt_fork).expect_err("helper fork mode must be refused");
assert!(matches!(error, ForkMemberError::InvalidRequest(_)));
validate_fork_spec(&spec("child")).expect("a Fresh spec is accepted");
}
#[test]
fn runtime_authoritative_labels_are_refused() {
let mut labelled = spec("child");
labelled.labels = Some(
[("agent_identity".to_string(), "spoofed".to_string())]
.into_iter()
.collect(),
);
assert!(matches!(
validate_fork_spec(&labelled),
Err(ForkMemberError::InvalidRequest(_))
));
let mut benign = spec("child");
benign.labels = Some(
[("team".to_string(), "review".to_string())]
.into_iter()
.collect(),
);
validate_fork_spec(&benign).expect("ordinary labels stay accepted");
}
#[test]
fn a_running_source_stays_typed_through_the_wrapper() {
let error = ForkMemberError::from(MobError::ForkSourceUnavailable {
source_member_id: "reviewer".to_string(),
cause: ForkSourceUnavailableCause::Running,
});
assert!(matches!(
error,
ForkMemberError::Mob(MobError::ForkSourceUnavailable {
cause: ForkSourceUnavailableCause::Running,
..
})
));
assert!(error.to_string().contains("running"));
assert!(!error.committed_fork_is_recoverable());
let absent = ForkMemberError::from(MobError::ForkSourceUnavailable {
source_member_id: "reviewer".to_string(),
cause: ForkSourceUnavailableCause::NoSession,
});
assert!(matches!(
absent,
ForkMemberError::Mob(MobError::ForkSourceUnavailable {
cause: ForkSourceUnavailableCause::NoSession,
..
})
));
}
#[test]
fn a_committed_fork_keeps_its_recovery_affordance() {
let fork_session_id = meerkat_core::types::SessionId::new();
let error = ForkMemberError::from(MobError::ForkMemberProvisionFailed {
member_id: AgentIdentity::from("child"),
fork_session_id: fork_session_id.clone(),
reason: "resume provisioning failed".to_string(),
});
assert!(error.committed_fork_is_recoverable());
let data = error
.structured_data()
.expect("a committed fork must publish structured recovery data");
assert_eq!(
data["kind"],
serde_json::json!("fork_member_provision_failed")
);
assert_eq!(
data["recovery"],
serde_json::json!("resume_committed_fork_session")
);
assert_eq!(
data["fork_session_id"],
serde_json::json!(fork_session_id.to_string()),
);
assert!(error.to_string().contains(&fork_session_id.to_string()));
}
#[test]
fn mobkit_side_refusals_publish_no_structured_recovery() {
assert!(
ForkMemberError::InvalidRequest("bad".to_string())
.structured_data()
.is_none()
);
assert!(
ForkMemberError::SourceAuthority("stale".to_string())
.structured_data()
.is_none()
);
}
#[test]
fn aliases_are_encoded_exactly_as_spawn_encodes_them() {
for alias in ["reviewer", "rt:review:singleton:0"] {
let identity = crate::member_comms_id::mob_member_id(alias);
assert_eq!(
crate::member_comms_id::runtime_alias_str(identity.as_str()).as_ref(),
alias,
"fork must round-trip the alias spawn seats",
);
}
assert_ne!(
crate::member_comms_id::mob_member_id("rt:review:singleton:0").as_str(),
"rt:review:singleton:0",
);
}
}