Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions crates/tui/src/tui/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2234,6 +2234,41 @@ async fn run_event_loop(
}
}
}
// Detect engine task death mid-turn. When the engine task
// panics (caught by spawn_supervised's catch_unwind) or exits
// unexpectedly between TurnStarted and TurnComplete, the event
// channel's sender is dropped. The while-let loop above exits
// silently on Err, so we must check post-loop and recover the
// UI state immediately instead of waiting for the 300-second
// TURN_STALL_WATCHDOG_TIMEOUT. Use is_closed() rather than
// try_recv() so the probe never consumes a valid event.
if (app.is_loading || app.is_compacting || app.is_purging) && rx.is_closed() {
streaming_thinking::finalize_current(app);
app.finalize_streaming_assistant_as_interrupted();
app.finalize_active_cell_as_interrupted();
app.streaming_state.reset();
app.streaming_message_index = None;
app.streaming_thinking_active_entry = None;

app.is_loading = false;
app.is_compacting = false;
app.is_purging = false;
app.active_allowed_tools = None;
app.agent_progress.clear();
app.agent_activity_started_at = None;
app.turn_started_at = None;
app.turn_last_activity_at = None;
app.runtime_turn_status = None;
app.runtime_turn_id = None;
app.dispatch_started_at = None;
app.user_scrolled_during_stream = false;
app.push_status_toast(
"Engine process has terminated unexpectedly.",
StatusToastLevel::Error,
None,
);
app.needs_redraw = true;
Comment on lines +2256 to +2270
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Pending steers silently lost on engine disconnect

The TurnComplete handler has an explicit "hard-fail recovery" path that calls app.drain_pending_steers() and requeues those messages so they are not silently lost (see the TurnOutcomeStatus::Failed branch). This disconnect recovery block is intended to substitute for that path when the engine dies without ever emitting TurnComplete, but it never drains app.pending_steers. Any steer messages the user composed mid-turn and held with Esc will be silently discarded rather than surfaced in the queue where the user can see and re-send them.

Fix in Codex Fix in Claude Code Fix in Cursor

}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
if let Some(index) = app.streaming_message_index {
let committed = app.streaming_state.commit_text(0);
Expand Down
67 changes: 67 additions & 0 deletions crates/tui/src/tui/ui/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2607,6 +2607,73 @@ fn turn_liveness_recovers_stalled_in_progress_turn() {
assert!(toast.text.contains("Turn stalled"));
}

#[test]
fn engine_event_channel_disconnect_recovers_mid_turn_ui_state() {
// Simulate the event-loop post-check that detects engine task death.
// Creates a real mpsc channel, sets up app as if a turn is in progress,
// drops the sender (mirroring engine task exit in spawn_supervised),
// and verifies the post-loop recovery logic cleans up the UI state.
let (tx_event, rx) = tokio::sync::mpsc::channel::<EngineEvent>(256);
drop(tx_event);

// Confirm the channel is closed
assert!(rx.is_closed());

let mut app = create_test_app();
app.is_loading = true;
app.is_compacting = false;
app.is_purging = false;
app.runtime_turn_status = Some("in_progress".to_string());
app.runtime_turn_id = Some("turn-42".to_string());
app.streaming_message_index = Some(0);
app.user_scrolled_during_stream = true;

// Apply the same post-loop logic from ui.rs
if (app.is_loading || app.is_compacting || app.is_purging) && rx.is_closed() {
streaming_thinking::finalize_current(&mut app);
app.finalize_streaming_assistant_as_interrupted();
app.finalize_active_cell_as_interrupted();
app.streaming_state.reset();
app.streaming_message_index = None;
app.streaming_thinking_active_entry = None;

app.is_loading = false;
app.is_compacting = false;
app.is_purging = false;
app.active_allowed_tools = None;
app.agent_progress.clear();
app.agent_activity_started_at = None;
app.turn_started_at = None;
app.turn_last_activity_at = None;
app.runtime_turn_status = None;
app.runtime_turn_id = None;
app.dispatch_started_at = None;
app.user_scrolled_during_stream = false;
app.push_status_toast(
"Engine process has terminated unexpectedly.",
StatusToastLevel::Error,
None,
);
app.needs_redraw = true;
}

// Verify the fix: UI state is fully recovered
assert!(!app.is_loading, "loading must be cleared");
assert!(!app.is_compacting, "compacting must be cleared");
assert!(!app.is_purging, "purging must be cleared");
assert!(app.active_allowed_tools.is_none());
assert!(app.agent_progress.is_empty());
assert!(app.agent_activity_started_at.is_none());
assert!(app.runtime_turn_status.is_none(), "turn status cleared");
assert!(app.runtime_turn_id.is_none(), "turn id cleared");
assert!(app.streaming_message_index.is_none());
assert!(!app.user_scrolled_during_stream);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
assert!(app.needs_redraw, "needs_redraw must be set");
let toast = app.status_toasts.back().expect("error toast pushed");
assert_eq!(toast.level, StatusToastLevel::Error);
assert!(toast.text.contains("Engine process has terminated"));
}

#[test]
fn fixed_model_auto_thinking_skips_auto_model_router() {
let mut app = create_test_app();
Expand Down
Loading