impl StateMachine {
fn validate_program_configuration(&self) -> Result<(), StateMachineLoadError> {
for (state_name, state) in &self.states {
if let Some(program) = &state.program {
validate_program_value(state_name, program)?;
if state.terminal {
return Err(StateMachineLoadError::Invalid(format!(
"state '{state_name}' is final and cannot declare a 'program' (terminal states have no work to execute)"
)));
}
if state.gating {
return Err(StateMachineLoadError::Invalid(format!(
"state '{state_name}' is gating and cannot declare a 'program' (gating states require human action)"
)));
}
}
}
for transition in &self.transitions {
if transition.exit_code.is_none() {
continue;
}
let Some(from_state) = self.states.get(&transition.from.0) else {
continue;
};
if from_state.program.is_none() {
return Err(StateMachineLoadError::Invalid(format!(
"transition from '{}' to '{}' declares 'exit_code' but source state '{}' does not declare a program",
transition.from.0, transition.to.0, transition.from.0
)));
}
}
Ok(())
}
fn validate_poll_configuration(&self) -> Result<(), StateMachineLoadError> {
for (state_name, state) in &self.states {
let Some(poll) = state.poll.as_ref() else { continue };
if parse_duration_secs(&poll.interval).is_none() {
return Err(StateMachineLoadError::Invalid(format!(
"state '{state_name}' has poll.interval '{}' that is not a valid duration (expected e.g. '30s', '5m', '1h')",
poll.interval
)));
}
if poll.max_attempts < 1 {
return Err(StateMachineLoadError::Invalid(format!(
"state '{state_name}' has poll.max_attempts {} (must be >= 1)",
poll.max_attempts
)));
}
if state.terminal {
return Err(StateMachineLoadError::Invalid(format!(
"state '{state_name}' is final and cannot declare 'poll' (terminal states have no work to execute)"
)));
}
if state.gating {
return Err(StateMachineLoadError::Invalid(format!(
"state '{state_name}' is gating and cannot declare 'poll' (gating states require human action; polling executes autonomously)"
)));
}
if state.visits.is_some() {
return Err(StateMachineLoadError::Invalid(format!(
"state '{state_name}' declares both 'poll' and 'visits'; poll.max_attempts replaces the visits cap"
)));
}
if state.snapshot.as_ref().and_then(|snapshot| snapshot.inherit.as_ref()).is_some() {
return Err(StateMachineLoadError::Invalid(format!(
"state '{state_name}' declares both 'poll' and 'snapshot.inherit'; polling states cannot inherit snapshots in v1"
)));
}
let has_self_loop =
self.transitions.iter().any(|t| t.from.0 == *state_name && t.to.0 == *state_name);
if !has_self_loop {
return Err(StateMachineLoadError::Invalid(format!(
"state '{state_name}' declares 'poll' but has no self-loop transition; add a transition with from: {state_name} and to: {state_name} so the retry branch is reachable"
)));
}
}
Ok(())
}
fn validate_execute_on_configuration(&self) -> Result<(), StateMachineLoadError> {
for (state_name, state) in &self.states {
let Some(raw) = state.execute_on.as_deref() else { continue };
if ExecuteOn::parse(raw).is_none() {
return Err(StateMachineLoadError::Invalid(format!(
"state '{state_name}' has execute_on '{raw}' (expected one of {})",
ExecuteOn::VALUES.join(", ")
)));
}
if state.terminal {
return Err(StateMachineLoadError::Invalid(format!(
"state '{state_name}' is final and cannot declare 'execute_on' (terminal states have no work to execute)"
)));
}
if state.gating {
return Err(StateMachineLoadError::Invalid(format!(
"state '{state_name}' is gating and cannot declare 'execute_on' (gating states require human action; a supervisor executes autonomously)"
)));
}
if state.program.is_some() {
return Err(StateMachineLoadError::Invalid(format!(
"state '{state_name}' declares both 'program' and 'execute_on'; a supervisor is a continued agent session, not a program"
)));
}
if state.poll.is_some() {
return Err(StateMachineLoadError::Invalid(format!(
"state '{state_name}' declares both 'poll' and 'execute_on'; a state has one trigger: `poll:` (time) or `execute_on:` (its subtree)"
)));
}
if !state.all_targets.is_empty() || !state.all_models.is_empty() {
return Err(StateMachineLoadError::Invalid(format!(
"state '{state_name}' combines 'execute_on' with 'all_targets' or 'all_models'; a supervisor is one continued session, not a fanout"
)));
}
if !state.is_agent_bearing() {
return Err(StateMachineLoadError::Invalid(format!(
"state '{state_name}' declares 'execute_on' but is not agent-bearing; give it an 'agent', 'target', or 'model'"
)));
}
let has_self_loop =
self.transitions.iter().any(|t| t.from.0 == *state_name && t.to.0 == *state_name);
if !has_self_loop {
return Err(StateMachineLoadError::Invalid(format!(
"state '{state_name}' declares 'execute_on' but has no self-loop transition; add a transition with from: {state_name} and to: {state_name} so the release branch is reachable"
)));
}
}
Ok(())
}
fn validate_tooling_configuration(&self) -> Result<(), StateMachineLoadError> {
for (state_name, state) in &self.states {
validate_state_mcp_entries(state_name, state)?;
validate_state_skill_entries(state_name, state)?;
}
for transition in &self.transitions {
validate_transition_tooling_trigger(
transition,
transition.mcp_unavailable.as_ref(),
"mcp_unavailable",
)?;
validate_transition_tooling_trigger(
transition,
transition.skill_unavailable.as_ref(),
"skill_unavailable",
)?;
if transition.mcp_unavailable.is_some() || transition.skill_unavailable.is_some() {
if let Some(from_state) = self.states.get(&transition.from.0) {
if from_state.program.is_some() {
return Err(StateMachineLoadError::Invalid(format!(
"transition from '{}' to '{}' declares a tooling-unavailable trigger \
but source state '{}' is a program state (tooling triggers are agent-only)",
transition.from.0, transition.to.0, transition.from.0
)));
}
}
}
}
Ok(())
}
}