fix(tui): accept MCP status from a replacement engine (CW-7430)

A replaced engine (provider or workspace switch, /resume, /load, consent
recovery) starts its MCP event generation at 0. The App kept the previous
engine's high-water mark in mcp_snapshot_generation, and its guard drops any
McpSessionBoot or supervisor update below that mark. After a respawn, the
MCP panel and /mcp status therefore stayed stale until the new engine's
counter overtook the old one, or until /mcp itself reset the mark.

Every TUI engine replacement goes through spawn_tui_engine. It now takes the
App, builds the engine config itself (all nine call sites already passed
build_engine_config(app, config)), and resets the App's MCP generation mark
and its invalidated flag for the new engine. The old engine's channel is
dropped with its handle, so none of its events can arrive after the reset.

Verification on this tree:
- cargo fmt --all -- --check: clean.
- clippy -p codewhale-tui --all-targets --locked with CI's flags: clean.
- nextest, focused on mcp, respawn, provider switch, workspace switch,
  session state and engine replacement: 641 run, 641 passed, 14952
  skipped.
- npm test: 547 Node tests (540 passed, 7 skipped, 0 failed) and
  772 Vitest passed. npm run check:web: exit 0.
- Not run: an interactive provider switch in a real terminal.

Refs CW-7430

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NBKbhx19BRbZtvWK8rVKjz
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-15 20:53:11 -07:00
1 parent 53918dd21b
commit b5e270b690
6 files changed
+14 -18

No files matched your search

+5 -3
View File
@@ -489,8 +489,10 @@ mod memory_quick_add_tests {
}
}
fn spawn_tui_engine(config: EngineConfig, api_config: &Config) -> EngineHandle {
let handle = spawn_engine(config, api_config);
fn spawn_tui_engine(app: &mut App, api_config: &Config) -> EngineHandle {
let handle = spawn_engine(build_engine_config(app, api_config), api_config);
app.mcp_snapshot_generation = 0;
app.mcp_snapshot_generation_invalidated = false;
// Prime durable agent + coordination state through the same engine event
// used by later refreshes. All TUI engine replacements use this wrapper,
// so workspace switches and provider recovery cannot retain stale Work.
@@ -502,7 +504,7 @@ fn spawn_tui_engine(config: EngineConfig, api_config: &Config) -> EngineHandle {
/// before admitting any pending input. The existing engine remains the sole
/// owner of model-facing history and the frozen system prefix.
async fn spawn_tui_engine_with_session(app: &mut App, config: &Config) -> Result<EngineHandle> {
let handle = spawn_tui_engine(build_engine_config(app, config), config);
let handle = spawn_tui_engine(app, config);
let restored = async {
if !app.api_messages.is_empty() {
handle
+5 -7
View File
@@ -1283,8 +1283,7 @@ pub(crate) async fn apply_provider_fallback_switch(
}
let _ = engine_handle.send(Op::Shutdown).await;
let engine_config = build_engine_config(app, config);
*engine_handle = spawn_tui_engine(engine_config, config);
*engine_handle = spawn_tui_engine(app, config);
if !app.api_messages.is_empty() {
let _ = engine_handle
@@ -1593,7 +1592,7 @@ async fn apply_command_result_inner(
sync_runtime_workspace_state(task_manager, app.workspace.clone()).await;
if respawn {
let _ = engine_handle.send(Op::Shutdown).await;
*engine_handle = spawn_tui_engine(build_engine_config(app, config), config);
*engine_handle = spawn_tui_engine(app, config);
} else {
let _ = engine_handle
.send(Op::SetModel {
@@ -1703,7 +1702,7 @@ async fn apply_command_result_inner(
app.update_model_compaction_budget();
if provider_changed || workspace_changed {
let _ = engine_handle.send(Op::Shutdown).await;
*engine_handle = spawn_tui_engine(build_engine_config(app, config), config);
*engine_handle = spawn_tui_engine(app, config);
}
// SyncSession carries the conversation but not resolved route
// limits. Refresh the engine's model first so a loaded,
@@ -1830,7 +1829,7 @@ async fn apply_command_result_inner(
);
}
let _ = engine_handle.send(Op::Shutdown).await;
*engine_handle = spawn_tui_engine(build_engine_config(app, config), config);
*engine_handle = spawn_tui_engine(app, config);
if !app.api_messages.is_empty() {
let _ = engine_handle
.send(Op::SyncSession {
@@ -2929,8 +2928,7 @@ async fn apply_command_result_inner(
crate::config::initialize_cloud_facts(config);
// Rebuild the engine with the new config so API key/model/base URL take effect.
let _ = engine_handle.send(Op::Shutdown).await;
let engine_config = build_engine_config(app, config);
*engine_handle = spawn_tui_engine(engine_config, config);
*engine_handle = spawn_tui_engine(app, config);
if !app.api_messages.is_empty() {
let _ = engine_handle
.send(Op::SyncSession {
+1 -2
View File
@@ -4542,8 +4542,7 @@ pub(crate) async fn run_event_loop(
}
if let Some(rollback_warning) = respawn_after_provider_rollback {
let _ = engine_handle.send(Op::Shutdown).await;
let engine_config = build_engine_config(app, config);
engine_handle = spawn_tui_engine(engine_config, config);
engine_handle = spawn_tui_engine(app, config);
if !app.api_messages.is_empty() {
let _ = engine_handle
.send(Op::SyncSession {
+1 -2
View File
@@ -2030,8 +2030,7 @@ pub(crate) async fn handle_view_events(
// only while a saturated engine finishes its turn.
if respawn {
let _ = engine_handle.send(Op::Shutdown).await;
*engine_handle =
spawn_tui_engine(build_engine_config(app, config), config);
*engine_handle = spawn_tui_engine(app, config);
} else {
let _ = engine_handle
.send(Op::SetModel {
+1 -2
View File
@@ -787,8 +787,7 @@ pub(crate) async fn switch_provider(
}
let _ = engine_handle.send(Op::Shutdown).await;
let engine_config = build_engine_config(app, config);
*engine_handle = spawn_tui_engine(engine_config, config);
*engine_handle = spawn_tui_engine(app, config);
// A successful in-session switch must refresh the same key-scoped live
// catalog as startup. TelecomJS is currently the only provider using this
// seam; failures preserve the existing/static rows.
+1 -2
View File
@@ -915,8 +915,7 @@ pub(crate) async fn switch_workspace(
sync_runtime_workspace_state(task_manager, workspace.clone()).await;
let _ = engine_handle.send(Op::Shutdown).await;
let engine_config = build_engine_config(app, config);
*engine_handle = spawn_tui_engine(engine_config, config);
*engine_handle = spawn_tui_engine(app, config);
if !app.api_messages.is_empty() {
let _ = engine_handle
.send(Op::SyncSession {