affaan-m/ECC · error · anyhow::Error

Invalid session state transition: {} -> {}

Error message

Invalid session state transition: {} -> {}

What it means

Raised by update_state in ecc2/src/session/store.rs:1325 when the current SessionState cannot legally transition to the requested one per can_transition_to (mod.rs:347). The allowed graph: Pending->{Running,Failed,Stopped}; Running->{Idle,Stale,Completed,Failed,Stopped}; Idle/Stale->{Running,Idle,Stale,Completed,Failed,Stopped}; only Completed->Stopped and Failed->Stopped are valid from terminal states. Anything else (e.g. Pending->Idle, Stopped->Running, Completed->Running) bails.

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 01e15490f0)

Solutions

  1. Check the transition legality with current_state.can_transition_to(&desired) before calling update_state, and branch on illegal transitions (e.g. create a new session instead of resurrecting).
  2. For watchdog/reaper code, skip Stale updates on terminal (Completed/Failed/Stopped) states.
  3. Order state events by timestamp or use a single state owner to prevent out-of-order terminal transitions.
  4. If resurrection is genuinely required, insert a new session row rather than transitioning a terminal one.

Example fix

// before: blind transition that may be illegal
store.update_state(&id, &SessionState::Running)?;

// after: gate on the legal graph, branch on rejection
let current = store.get_session(&id)?.and_then(|s| s.state.ok_or(()));
let desired = SessionState::Running;
if !current.can_transition_to(&desired) {
    // create a fresh session instead of resurrecting a terminal one
    return Ok(store.create_session(...)?.id);
}
store.update_state(&id, &desired)?;
Defensive patterns

Strategy: validation

Validate before calling

// Check can_transition_to before calling update_state and branch on refusal.
use crate::session::SessionState;

fn transition_or_recreate(store: &Store, id: &str, desired: SessionState) -> anyhow::Result<()> {
    let session = store.get_session(id)?
        .ok_or_else(|| anyhow::anyhow!("no session matches {id}"))?;
    let current = session.state;
    if !current.can_transition_to(&desired) {
        if matches!(current, SessionState::Stopped | SessionState::Completed | SessionState::Failed)
            && desired == SessionState::Running
        {
            anyhow::bail!("cannot resurrect {id}; create a new session instead");
        }
        return Ok(()); // ignore no-op / illegal transitions
    }
    store.update_state(id, &desired)
}

Type guard

// A SessionState guard is already provided by the enum's can_transition_to.
// Wrap it in a function returning Result to centralize policy.
pub fn assert_legal_transition(from: &SessionState, to: &SessionState) -> anyhow::Result<()> {
    if from.can_transition_to(to) { Ok(()) } else {
        anyhow::bail!("illegal transition {from} -> {to}")
    }
}

Try / catch

// Distinguish illegal-transition from not-found in the catch.
match store.update_state(&id, &next) {
    Ok(()) => Ok(()),
    Err(e) if e.to_string().contains("Invalid session state transition") => {
        tracing::warn!("illegal transition for {id}; ignoring");
        Ok(())
    }
    Err(e) if e.to_string().contains("Session not found") => {
        tracing::warn!("session {id} gone during transition");
        Ok(())
    }
    Err(e) => Err(e),
}

Prevention

When it happens

Trigger: Attempting to mark a Stopped session Running (resurrection); transitioning Pending directly to Idle or Completed without going through Running; calling update_state(Completed) on a session already in Stopped state; a watchdog marking a Completed session Stale.

Common situations: Restart logic that tries to revive a stopped/failed session in place instead of creating a new one; a heartbeat watchdog that sets Stale on a Completed row before the completion update lands; out-of-order event processing where Completed arrives after Stopped.

Related errors


AI-assisted analysis of affaan-m/ECC@01e15490f0 (2026-08-13). Data as JSON: /api/errors/9fc427c06cf58e99. Report an issue: GitHub.