{"record":{"id":"ee70f9dc578085f0","repo":"Hmbown/CodeWhale","slug":"message-dispatch-belongs-to-a-previous-session","errorCode":null,"errorMessage":"Message dispatch belongs to a previous session","messagePattern":"Message dispatch belongs to a previous session","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"info","filePath":"crates/tui/src/tui/ui/dispatch.rs","lineNumber":1022,"sourceCode":"pub(crate) fn build_dispatch_error_closure(\n    prepare: UserDispatchPrepare,\n    recovery: DispatchRecovery,\n    error: String,\n) -> crate::tui::app::DispatchApplyFn {\n    Box::new(\n        move |app: &mut App,\n              _engine_handle: &EngineHandle,\n              _config: &Config|\n              -> anyhow::Result<()> {\n            app.dispatch_in_flight = false;\n            // No operation was admitted, including route/reservation failures:\n            // retire only cancellation introduced by this dispatch. A previous\n            // admitted turn may still need to suppress its queued events.\n            if !prepare.snapshot.suppress_stream_events_until_turn_complete {\n                app.suppress_stream_events_until_turn_complete = false;\n            }\n            if prepare.cost_scope != crate::cost_status::scope_token() {\n                anyhow::bail!(\"Message dispatch belongs to a previous session\");\n            }\n            app.remote_control.fail_active_dispatch(&error);\n            // Roll back the optimistic sync prepare mutations.\n            app.is_loading = prepare.snapshot.is_loading;\n            app.runtime_turn_status = prepare.snapshot.runtime_turn_status.clone();\n            app.receipt_text = prepare.snapshot.receipt_text.clone();\n            app.receipt_started_at = prepare.snapshot.receipt_started_at;\n            app.tool_evidence = prepare.snapshot.tool_evidence.clone();\n            let keep_user_echo = is_missing_credential_dispatch_error(&error);\n            if keep_user_echo {\n                // Echo first: keep HistoryCell::User painted in prepare. Drop only\n                // the unsent api_messages append and loading chrome.\n                app.truncate_api_messages(prepare.snapshot.api_messages_len);\n                app.last_send_at = prepare.snapshot.last_send_at;\n            } else {\n                app.history.truncate(prepare.snapshot.history_len);\n                app.prune_transcript_index_state(prepare.snapshot.history_len);\n                app.history_revisions","sourceCodeStart":1004,"sourceCodeEnd":1040,"githubUrl":"https://github.com/Hmbown/CodeWhale/blob/73e0f67d83c59909b571efdfc88c4bc28c309cb1/crates/tui/src/tui/ui/dispatch.rs#L1004-L1040","documentation":"The dispatch error/rollback closure re-checks the cost-scope token captured at prepare time. If it changed, the failure belongs to a previous session, so the closure skips rollback/app-side retirement rather than mutating the current session with stale state. Thrown from `build_dispatch_error_closure` on the failure path only.","triggerScenarios":"A prepared dispatch fails (send error, closed channel) and, by the time its error closure runs, `crate::cost_status::scope_token()` no longer equals `prepare.cost_scope` — i.e. the session/turn scope changed between prepare and failure handling.","commonSituations":"Dispatch fails slowly (network) while the user already started a new turn or session; error closure fires after a session swap; test scenarios simulating cancellation ownership across turns.","solutions":["Nothing to fix per se — the stale dispatch was intentionally discarded; re-send the message in the current session","If it appears unexpectedly, verify nothing is rotating `cost_status::scope_token()` outside of genuine turn/session boundaries","Reproduce with a fresh turn instead of relying on queued state from the old session"],"exampleFix":null,"handlingStrategy":"type-guard","validationCode":"if prepare.cost_scope != crate::cost_status::scope_token() {\n    // failure belongs to a previous session; skip rollback, do not mutate app state\n    return;\n}","typeGuard":"fn failure_is_for_current_session(prepare: &Prepare) -> bool {\n    prepare.cost_scope == crate::cost_status::scope_token()\n}","tryCatchPattern":"if let Err(err) = send.await {\n    if prepare.cost_scope == crate::cost_status::scope_token() {\n        app.remote_control.fail_active_dispatch(&err);\n        rollback(prepare);\n    } // else: previous session; intentionally no-op\n}","preventionTips":["Always compare the captured cost-scope token before mutating app state from async closures","Design error closures to be no-ops when the session scope changed","Keep turn/session boundaries the only place `scope_token()` rotates"],"tags":["rust","async","session","lifecycle"],"backgroundTag":"stale-session-state","analyzedSha":"73e0f67d83c59909b571efdfc88c4bc28c309cb1","analyzedAt":"2026-09-22T01:30:00.501Z","contentChangedAt":"2026-09-22T01:30:00.501Z","schemaVersion":2},"datasetVersion":"2026-09-23T02:17:17.105Z"}