Hmbown/CodeWhale · critical

engine event channel closed before turn

Error message

engine event channel closed before turn {turn_id} completed

What it means

During a turn the worker awaits events from the engine on a channel. If the channel closes (all engine senders dropped) before the turn finished and no interrupt was observed, the turn can never complete, so the worker bails with this error. It converts a silent hang into an explicit failure.

Solutions

  1. Investigate why the engine task exited (panic logs, shutdown path) before the turn finished
  2. Retry the turn after restarting the engine/runtime session
  3. Check for explicit shutdown calls (drop of engine handle) racing in-flight turns and serialize them

Example fix

// before
let replay = thread.start_turn(turn_id).await?; // engine may already be gone
// after
if thread.engine_alive().await {
    thread.start_turn(turn_id).await?;
} else {
    thread.restart_engine().await?;
    thread.start_turn(turn_id).await?;
}
Defensive patterns

Strategy: retry

Validate before calling

if !engine_handle.is_alive() { engine.restart().await?; }

Try / catch

loop {
    match thread.run_turn(turn_id).await {
        Err(e) if e.to_string().contains("engine event channel closed") => {
            engine.restart().await?;
            continue; // with backoff
        }
        other => break other,
    }
}

Prevention

When it happens

Trigger: The engine task is dropped/panicked or its event sender is closed while awaiting the next event for turn_id, and the interrupt check (which would set RuntimeTurnStatus::Interrupted) did not fire.

Common situations: Engine crash or panic mid-turn; runtime shutdown while a turn is in flight; a bug dropping the engine handle prematurely; process teardown racing an active turn.

Related errors


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

Appendix: source

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

        loop {
            let event = if let Some(event) = pending_event.take() {
                Some(event)
            } else if event_channel_closed {
                None
            } else {
                let mut rx = engine.rx_event.write().await;
                rx.recv().await
            };
            let Some(event) = event else {
                if self
                    .is_interrupt_requested(&thread_id, &turn_id)
                    .await
                    .unwrap_or(false)
                {
                    turn_status = Some(RuntimeTurnStatus::Interrupted);
                    break;
                }
                bail!("engine event channel closed before turn {turn_id} completed");
            };

            // SyncSession and configuration operations emit control status
            // receipts on the same channel before SendMessage is processed.
            // They belong to engine setup, not to the next claimed turn.
            if !saw_turn_started
                && matches!(
                    &event,
                    EngineEvent::Status { .. }
                        | EngineEvent::McpSessionBoot { .. }
                        | EngineEvent::SessionUpdated { .. }
                        | EngineEvent::AgentList { .. }
                        | EngineEvent::AgentSpawned { .. }
                        | EngineEvent::AgentProgress { .. }
                        | EngineEvent::AgentComplete { .. }
                        | EngineEvent::SubAgentMailbox { .. }
                )
            {

View on GitHub (pinned to 73e0f67d83)