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

  1. Check the session's current state first and only call update_state when can_transition_to allows the move.
  2. Make transitions idempotent in your caller: if current == target, skip the call instead of erroring.
  3. Serialize session mutations (single writer / locking) so enforcer loops don't race stop/start flows.
  4. 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

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


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)