Hmbown/CodeWhale · warning
Message dispatch belongs to a previous engine or session
Error message
Message dispatch belongs to a previous engine or session
What it means
When the spawned dispatch finally executes, it verifies that the engine channel it captured still matches the TUI app's current engine and that the cost scope token is unchanged. If the engine was replaced (new session/turn) or the scope moved on, the message is refused instead of being delivered to a stale engine.
Solutions
- Re-submit the message through the normal input path so it binds to the current engine
- Avoid restarting the session/turn while a dispatch is pending; wait for the send to complete first
- If it recurs, check for a bug recreating the engine on every input (channel identity churn)
Defensive patterns
Strategy: try-catch
Validate before calling
let current = app.current_engine();
if !engine_handle.tx_op.same_channel(¤t.tx_op)
|| prepare.cost_scope != crate::cost_status::scope_token() {
eprintln!("dispatch is stale; re-submitting");
requeue(message);
return;
} Type guard
fn dispatch_is_current(h: &EngineHandle, prepare: &Prepare) -> bool {
h.tx_op.same_channel(¤t_engine().tx_op)
&& prepare.cost_scope == crate::cost_status::scope_token()
} Try / catch
match spawned_dispatch_execute(...).await {
Err(e) if e.to_string().contains("previous engine or session") => {
requeue_for_current_engine(message); // surface as cancelled, not failed
}
r => r,
} Prevention
- Don't cancel/restart the session while a queued dispatch is pending
- Re-submit messages after any session or engine change instead of expecting old dispatches to land
- Treat stale dispatch as cancellation; never retry the captured engine handle
When it happens
Trigger: Calling `spawned_dispatch_inner` (via spawned_dispatch_execute / run_prepared_dispatch) after the app swapped to a new engine instance (`engine_handle.tx_op` is on a different channel) or after `crate::cost_status::scope_token()` changed, e.g. the user cancelled and started a new turn or reopened a session while the dispatch task was pending.
Common situations: Queued message dispatched just as the user pressed Escape/new-session; slow async dispatch racing a session restart; dispatch tasks surviving a model switch that recreated the engine.
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
- Message dispatch belongs to a previous session
- cancelled tool result is always model-visible
- child-local search
- {err}
- Failed to steer turn
AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22).
Data as JSON: /api/errors/9566edd39a9ca77c.
Report an issue: GitHub.
Appendix: source
Thrown at crates/tui/src/tui/ui/dispatch.rs:869
effective_provider_identity,
effective_provider_label,
effective_reasoning_effort: effective_reasoning_receipt,
auto_selection,
};
Box::new(move |app, current_engine, config| {
// Admission stays serialized by this flag until its callback retires,
// even if the user replaced the Engine/session while routing waited.
app.dispatch_in_flight = false;
// This request has no admitted Op and cannot emit TurnComplete. Retire
// its local cancellation even after replacement, but leave a previous
// admitted turn's suppression for that turn's terminal event to retire.
if !prepare.snapshot.suppress_stream_events_until_turn_complete {
app.suppress_stream_events_until_turn_complete = false;
}
if !engine_handle.tx_op.same_channel(¤t_engine.tx_op)
|| prepare.cost_scope != crate::cost_status::scope_token()
{
anyhow::bail!("Message dispatch belongs to a previous engine or session");
}
if !app.is_loading || engine_handle.tx_op.is_closed() {
let error = if engine_handle.tx_op.is_closed() {
"Engine stopped before accepting the message"
} else {
"Message dispatch was cancelled before it reached the engine"
};
return build_dispatch_error_closure(prepare, recovery, error.to_string())(
app,
&engine_handle,
config,
);
}
build_dispatch_success_closure(prepare, outcome)(app, &engine_handle, config)?;
// Existing Engine admission binds cancellation controls and the Op in
// one FIFO. No await separates the UI checkpoint from this handoff.
engine_handle.send_reserved_op(permit, op);
drop(usage.batch.take());View on GitHub (pinned to 73e0f67d83)