affaan-m/ECC · error · anyhow::Error
Invalid session state transition
Error message
Invalid session state transition: {} -> {} What it means
SessionStore::update_state enforces a finite state machine on session lifecycle states. It loads the current state and calls SessionState::can_transition_to; if the requested new state is not an allowed transition from the current one, it bails with 'Invalid session state transition: {old} -> {new}'. This prevents illegal jumps like Running -> Pending or updates to a terminal state.
Solutions
- Check the session's current state first and only call update_state when can_transition_to allows the move.
- Make transitions idempotent in your caller: if current == target, skip the call instead of erroring.
- Serialize session mutations (single writer / locking) so enforcer loops don't race stop/start flows.
- Catch the error and re-read the current state to decide whether the transition was already handled.
Example fix
// before
store.update_state(&session_id, SessionState::Running)?;
// after
let s = store.get_session(&session_id)?.ok_or_else(|| anyhow::anyhow!("no session"))?;
if s.state != SessionState::Running {
store.update_state(&session_id, SessionState::Running)?;
} Defensive patterns
Strategy: validation
Validate before calling
let current = store.get_session(&id)?.ok_or_else(|| anyhow::anyhow!("no session {id}"))?.state;
if current.can_transition_to(&target) {
store.update_state(&id, target)?;
} Type guard
fn can_apply(current: &SessionState, target: &SessionState) -> bool {
current.can_transition_to(target)
} Try / catch
match store.update_state(&id, target) {
Err(e) if e.to_string().contains("Invalid session state transition") => {
let cur = store.get_session(&id)?; // reconcile with DB state
}
other => other?,
} Prevention
- Make transition callers idempotent: skip when current == target.
- Serialize state changes per session (single writer or row lock).
- Model the allowed transition table explicitly and unit-test it.
When it happens
Trigger: Calling update_state(session_id, new_state) where the session's current DB state cannot legally move to new_state — e.g. activating an already-active session, queuing a session that is Stopped, or double-stopping via stop_session_recorded.
Common situations: Heartbeat enforcers racing a stop (session moves to Stopped mid-flight, then an enforcer tries Running->Running or Running->Completed); create_session_in_dir/queue_session_with_resolved_profile_and_runner_program retrying activation after a prior transition already succeeded; duplicate webhook or timer callbacks firing the same transition twice.
Understand the failure class
Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.
Related errors
- obligation completion failed
- an active harness configuration already exists
- baseline is not the active harness configuration
- Cannot delete active session
- Cannot merge active session
AI-assisted analysis of affaan-m/ECC@8321021c54 (2026-09-16).
Data as JSON: /api/errors/9fc427c06cf58e99.
Report an issue: GitHub.
Appendix: source
Thrown at ecc2/src/session/store.rs:1325
self.refresh_session_board_meta()?;
Ok(())
}
pub fn update_state(&self, session_id: &str, state: &SessionState) -> Result<()> {
let current_state = self
.conn
.query_row(
"SELECT state FROM sessions WHERE id = ?1",
[session_id],
|row| row.get::<_, String>(0),
)
.optional()?
.map(|raw| SessionState::from_db_value(&raw))
.ok_or_else(|| anyhow::anyhow!("Session not found: {session_id}"))?;
if !current_state.can_transition_to(state) {
anyhow::bail!(
"Invalid session state transition: {} -> {}",
current_state,
state
);
}
let updated = self.conn.execute(
"UPDATE sessions
SET state = ?1,
updated_at = ?2,
last_heartbeat_at = ?2
WHERE id = ?3",
rusqlite::params![
state.to_string(),
chrono::Utc::now().to_rfc3339(),
session_id,
],
)?;View on GitHub (pinned to 8321021c54)