{"record":{"id":"9fc427c06cf58e99","repo":"affaan-m/ECC","slug":"invalid-session-state-transition","errorCode":null,"errorMessage":"Invalid session state transition: {} -> {}","messagePattern":"Invalid session state transition: (.+?) -> (.+?)","errorType":"validation","errorClass":"anyhow::Error","httpStatus":null,"severity":"error","filePath":"ecc2/src/session/store.rs","lineNumber":1325,"sourceCode":"\n        self.refresh_session_board_meta()?;\n        Ok(())\n    }\n\n    pub fn update_state(&self, session_id: &str, state: &SessionState) -> Result<()> {\n        let current_state = self\n            .conn\n            .query_row(\n                \"SELECT state FROM sessions WHERE id = ?1\",\n                [session_id],\n                |row| row.get::<_, String>(0),\n            )\n            .optional()?\n            .map(|raw| SessionState::from_db_value(&raw))\n            .ok_or_else(|| anyhow::anyhow!(\"Session not found: {session_id}\"))?;\n\n        if !current_state.can_transition_to(state) {\n            anyhow::bail!(\n                \"Invalid session state transition: {} -> {}\",\n                current_state,\n                state\n            );\n        }\n\n        let updated = self.conn.execute(\n            \"UPDATE sessions\n             SET state = ?1,\n                 updated_at = ?2,\n                 last_heartbeat_at = ?2\n             WHERE id = ?3\",\n            rusqlite::params![\n                state.to_string(),\n                chrono::Utc::now().to_rfc3339(),\n                session_id,\n            ],\n        )?;","sourceCodeStart":1307,"sourceCodeEnd":1343,"githubUrl":"https://github.com/affaan-m/ECC/blob/01e15490f04e29cfefe3896951f43db46994d8ee/ecc2/src/session/store.rs#L1307-L1343","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["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).","For watchdog/reaper code, skip Stale updates on terminal (Completed/Failed/Stopped) states.","Order state events by timestamp or use a single state owner to prevent out-of-order terminal transitions.","If resurrection is genuinely required, insert a new session row rather than transitioning a terminal one."],"exampleFix":"// before: blind transition that may be illegal\nstore.update_state(&id, &SessionState::Running)?;\n\n// after: gate on the legal graph, branch on rejection\nlet current = store.get_session(&id)?.and_then(|s| s.state.ok_or(()));\nlet desired = SessionState::Running;\nif !current.can_transition_to(&desired) {\n    // create a fresh session instead of resurrecting a terminal one\n    return Ok(store.create_session(...)?.id);\n}\nstore.update_state(&id, &desired)?;","handlingStrategy":"validation","validationCode":"// Check can_transition_to before calling update_state and branch on refusal.\nuse crate::session::SessionState;\n\nfn transition_or_recreate(store: &Store, id: &str, desired: SessionState) -> anyhow::Result<()> {\n    let session = store.get_session(id)?\n        .ok_or_else(|| anyhow::anyhow!(\"no session matches {id}\"))?;\n    let current = session.state;\n    if !current.can_transition_to(&desired) {\n        if matches!(current, SessionState::Stopped | SessionState::Completed | SessionState::Failed)\n            && desired == SessionState::Running\n        {\n            anyhow::bail!(\"cannot resurrect {id}; create a new session instead\");\n        }\n        return Ok(()); // ignore no-op / illegal transitions\n    }\n    store.update_state(id, &desired)\n}","typeGuard":"// A SessionState guard is already provided by the enum's can_transition_to.\n// Wrap it in a function returning Result to centralize policy.\npub fn assert_legal_transition(from: &SessionState, to: &SessionState) -> anyhow::Result<()> {\n    if from.can_transition_to(to) { Ok(()) } else {\n        anyhow::bail!(\"illegal transition {from} -> {to}\")\n    }\n}","tryCatchPattern":"// Distinguish illegal-transition from not-found in the catch.\nmatch store.update_state(&id, &next) {\n    Ok(()) => Ok(()),\n    Err(e) if e.to_string().contains(\"Invalid session state transition\") => {\n        tracing::warn!(\"illegal transition for {id}; ignoring\");\n        Ok(())\n    }\n    Err(e) if e.to_string().contains(\"Session not found\") => {\n        tracing::warn!(\"session {id} gone during transition\");\n        Ok(())\n    }\n    Err(e) => Err(e),\n}","preventionTips":["Always call can_transition_to before update_state; branch on the result.","Never try to resurrect Stopped/Completed/Failed sessions; create a new session row instead.","Make watchdogs skip Stale updates on terminal states.","Order state events by timestamp when processing from a queue."],"tags":["database","state-machine","session","validation","transition"],"backgroundTag":null,"analyzedSha":"01e15490f04e29cfefe3896951f43db46994d8ee","analyzedAt":"2026-08-13T00:31:08.655Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}