Hmbown/CodeWhale · error

settlement turn already belongs to another thread

Error message

settlement turn {turn_id} already belongs to another thread

What it means

When applying routed-usage settlement, the store loads the settlement turn if it already exists on disk. If the existing turn's `thread_id` differs from the thread the settlement is being recorded for, the operation bails rather than re-parenting a turn to another thread — this protects turn/thread ownership invariants.

Solutions

  1. Use the thread id that owns the existing turn when recording settlement.
  2. Generate a fresh turn id for the new thread instead of reusing the colliding one.
  3. Investigate why `unaccepted_routed_usage_turn_id` produced an id already owned by another thread (id derivation bug or stale batch).

Example fix

// before
store.save_settlement_turn(&wrong_thread_id, &batch)?;
// after
let existing = store.load_turn(&turn_id)?;
store.save_settlement_turn(&existing.thread_id, &batch)?; // record under the owning thread
Defensive patterns

Strategy: try-catch

Validate before calling

fn settlement_thread_ok(existing_thread: &str, target: &str) -> bool { existing_thread == target }

Try / catch

match store.save_settlement_turn(thread_id, batch) { Err(e) if e.contains("already belongs to another thread") => record_under_owning_thread(e), Err(e) => return Err(e.into()), Ok(id) => Ok(id) }

Prevention

When it happens

Trigger: Calling the settlement save with a `thread_id` different from the one the existing turn file was created under, i.e. the same turn id is claimed by two threads.

Common situations: A turn id collision or reuse across threads; a thread id changed after the turn was first written; replaying settlement batches against a re-keyed thread.

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/2690a4677b6096ec. Report an issue: GitHub.

Appendix: source

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

///
/// The record is terminal on creation and deliberately carries no parent
/// `usage`, no parent route, and no thread-pointer update: the parent turn was
/// never accepted, so only the auxiliary call's own frozen route may be
/// charged. Because no engine `TurnComplete` will ever arrive for it, this
/// record — unlike an accepted turn — also owns the coverage gap its bounded
/// ledger could not represent. Every persisted field is re-derived from the
/// batch, so replaying the same settlement rewrites the same values.
fn settle_unaccepted_routed_usage(
    store: &RuntimeThreadStore,
    thread_id: &str,
    batch: &crate::cost_status::RuntimeUsageBatch,
) -> Result<String> {
    let turn_id = unaccepted_routed_usage_turn_id(thread_id, batch);
    let _turn_mutation = store.turn_mutation.lock();
    let mut turn = if store.turn_path(&turn_id)?.exists() {
        let existing = store.load_turn(&turn_id)?;
        if existing.thread_id != thread_id {
            bail!("settlement turn {turn_id} already belongs to another thread");
        }
        existing
    } else {
        let now = Utc::now();
        TurnRecord {
            max_output_tokens: None,
            schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
            id: turn_id.clone(),
            thread_id: thread_id.to_string(),
            status: RuntimeTurnStatus::Failed,
            input_summary: UNACCEPTED_TURN_SUMMARY.to_string(),
            created_at: now,
            started_at: Some(now),
            ended_at: Some(now),
            duration_ms: Some(0),
            usage: None,
            routing_settlement: true,
            effective_route_usage: None,

View on GitHub (pinned to 73e0f67d83)