Hmbown/CodeWhale · error

Failed to start turn: engine operation channel closed

Error message

Failed to start turn: engine operation channel closed

What it means

To start a turn, the runtime reserves a slot on the engine's operation channel via engine.tx_op.clone().reserve_owned().await. If the channel is closed (receiver dropped — the engine task is gone), it maps the error to 'Failed to start turn: engine operation channel closed'. This means no engine task is alive to accept the turn operation.

Solutions

  1. Ensure no Op::Shutdown was sent to this engine before starting the turn
  2. Check engine task logs for a panic/abort that closed the channel
  3. Reinstall the engine for the thread and retry the turn start
  4. Serialize shutdown against turn starts so starts cannot race teardown

Example fix

// before
let permit = engine.tx_op.clone().reserve_owned().await
    .map_err(|_| anyhow!("Failed to start turn: engine operation channel closed"))?;
// after
if !engine.is_alive() {
    bail!("engine for {thread_id} is not running; reinstall before starting a turn");
}
let permit = engine.tx_op.clone().reserve_owned().await
    .map_err(|_| anyhow!("Failed to start turn: engine operation channel closed"))?;
Defensive patterns

Strategy: try-catch

Validate before calling

if !engine_is_alive(&engine) { bail!("engine already shut down; cannot start turn"); }

Type guard

fn engine_is_alive(engine: &EngineHandle) -> bool { !engine.tx_op.is_closed() }

Try / catch

match start_turn(req).await {
    Err(e) if e.to_string().contains("operation channel closed") => {
        // engine task died: reinstall and retry once
        manager.install_engine(&req.thread_id).await?;
        start_turn(req).await
    }
    other => other,
}

Prevention

When it happens

Trigger: Calling start_turn after the engine task for the thread has terminated (crashed, was aborted, or already received Op::Shutdown); racing shutdown while a turn is being started; engine worker death that was not cleaned out of active.engines.

Common situations: Tearing down the runtime while a caller still starts turns; an engine panic that dropped the op receiver; sequencing bugs where Shutdown was sent before the last turn start.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22). Data as JSON: /api/errors/a402a654a9668a64. Report an issue: GitHub.

Appendix: source

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

            auto_approve,
            translation_enabled: false,
            allowed_tools,
            dynamic_tools: req.dynamic_tools,
            hook_executor: None,
            approval_mode: policy.permission,
            verbosity,
            provenance: input_source.provenance(),
        });

        // Reserve mailbox capacity before claiming or persisting anything.
        // If the caller is cancelled while capacity is unavailable, no
        // durable or in-memory turn state has changed.
        let permit = engine
            .tx_op
            .clone()
            .reserve_owned()
            .await
            .map_err(|_| anyhow!("Failed to start turn: engine operation channel closed"))?;

        let acceptance_rx = {
            // Lock order is active -> thread_mutation. Neither guard crosses
            // an await, and spawning the owned lifecycle task is synchronous.
            // The operation claim is an OS-backed file lock, so another
            // Runtime process sharing this store cannot pass the replay check
            // or persist a competing turn for the same operation key.
            let mut active = self.active.lock().await;
            let mut operation_claim = operation
                .as_ref()
                .map(|operation| {
                    self.store.open_turn_operation_claim_lock(
                        &operation.binding.operation_key_fingerprint,
                    )
                })
                .transpose()?
                .map(fd_lock::RwLock::new);
            let operation_claim_guard = operation_claim

View on GitHub (pinned to 73e0f67d83)