Hmbown/CodeWhale · warning

Turn moved on before the steer reached the model;

Error message

Turn {turn_id} moved on before the steer reached the model; {STEER_DROPPED_REASON}

What it means

Thrown when the engine explicitly reports `SteerOutcome::Dropped` on the settlement channel — the turn moved on (finished or advanced past the point where the steer could be injected) before the queued steer reached the model. The caller already returned a `Queued` receipt, so this error is the honest async correction that the steer was not delivered.

Solutions

  1. Fall back to starting a new turn containing the steering message so the user's input is never lost.
  2. Handle the Dropped outcome in the caller by updating the UI receipt from Queued to Dropped and prompting the user to resend.
  3. Steer earlier in the turn lifecycle: send steering input while the turn is clearly mid-stream rather than near its end.

Example fix

// before
let turn = runtime.steer(thread_id, turn_id, item).await?;
// after
match runtime.steer(thread_id, turn_id, item).await {
    Ok(turn) => turn,
    Err(e) if e.to_string().contains("moved on before the steer") => {
        runtime.start_turn(thread_id, item).await? // deliver as a new turn
    }
    Err(e) => return Err(e),
}
Defensive patterns

Strategy: fallback

Try / catch

match runtime.steer(thread_id, turn_id, item).await {
    Ok(turn) => Ok(turn),
    Err(e) if e.to_string().contains("moved on before the steer") => {
        runtime.start_turn(thread_id, item).await // resend as a fresh turn
    }
    Err(e) => Err(e),
}

Prevention

When it happens

Trigger: Awaiting the steer settlement future (`settle_rx` with STEER_SETTLE_WAIT timeout) and receiving `(SteerOutcome::Dropped, _)` — happens when the turn completes or is interrupted between acceptance of the queued item and the engine's next mailbox check.

Common situations: Steer sent just as the model emits its final message; steer queued behind a turn that ends before the next API request; UI shows 'queued' then must reconcile with the drop.

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 Hmbown/CodeWhale@73e0f67d83 (2026-09-22). Data as JSON: /api/errors/eb030e844917aa2b. Report an issue: GitHub.

Appendix: source

Thrown at crates/tui/src/runtime_threads.rs:10492

                }
            };
            // The reserved send has no await/failure point. From here the
            // engine and durable record agree even if the API caller drops.
            let outcome_rx = permit.send_with_outcome(prompt.clone());
            touch_lru(&mut active.lru, thread_id);
            queued_turn = turn.clone();
            self.spawn_steer_settlement(turn, item, prompt, outcome_rx)
        };

        // The settler owns the verdict; this is only an observation of it.
        // Steering a streaming turn settles in milliseconds, so the caller
        // almost always gets the truth in-band. Behind a long tool call the
        // engine will not look at its mailbox for minutes, and an API request
        // must not hang that long — so the wait is bounded and the caller
        // falls back to the honest `Queued` receipt it already has.
        match tokio::time::timeout(STEER_SETTLE_WAIT, settle_rx).await {
            Ok(Ok((SteerOutcome::Accepted, turn))) => Ok(turn),
            Ok(Ok((SteerOutcome::Dropped, _))) => bail!(
                "Turn {turn_id} moved on before the steer reached the model; {STEER_DROPPED_REASON}"
            ),
            Ok(Err(_)) => bail!("Steer settlement task ended before acknowledgement"),
            Err(_) => Ok(queued_turn),
        }
    }

    pub async fn compact_thread(
        &self,
        thread_id: &str,
        req: CompactThreadRequest,
    ) -> Result<TurnRecord> {
        // Compaction carries a concrete provider route just like a normal
        // turn. Keep the same reload/admission boundary through durable engine
        // handoff so it cannot dispatch an old credential or endpoint after a
        // successful config reload.
        let _config_admission = self.config_admission.read().await;
        self.ensure_accepting_execution()?;

View on GitHub (pinned to 73e0f67d83)