mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
This change fixes the case where the UI can sit on _"Starting MCP servers"_ even though the review work is already running or has already completed. - MCP startup status header is visible when a `/review` turn starts with enabled MCP server startups - Restore the underlying _Working..._ status after MCP startup completes or fails - Add regression coverage for overlapping startup/turn flows and status restoration _De-scoped from a broader thread-scoped MCP status change that would have made it easier to route MCP startup statuses to the appropriate thread (parent vs. review). These changes address the UI regression without requiring more significant changes across app-server & core._ Fixes #18792.
277 lines
11 KiB
Rust
277 lines
11 KiB
Rust
//! MCP startup state and status handling for the chat widget.
|
|
//!
|
|
//! The app server reports MCP server startup as per-server status updates. This
|
|
//! module keeps the TUI's buffered startup round state coherent and translates
|
|
//! those updates into status headers, warnings, and queued-input release points.
|
|
|
|
use std::collections::BTreeSet;
|
|
|
|
use codex_app_server_protocol::McpServerStartupState;
|
|
use codex_app_server_protocol::McpServerStatusUpdatedNotification;
|
|
|
|
use super::ChatWidget;
|
|
|
|
const MCP_STARTUP_SINGLE_HEADER_PREFIX: &str = "Booting MCP server:";
|
|
const MCP_STARTUP_MULTI_HEADER_PREFIX: &str = "Starting MCP servers";
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) enum McpStartupStatus {
|
|
Starting,
|
|
Ready,
|
|
Failed { error: String },
|
|
Cancelled,
|
|
}
|
|
|
|
impl ChatWidget {
|
|
/// Record one MCP startup update, promoting it into either the active startup
|
|
/// round or a buffered "next" round.
|
|
///
|
|
/// This path has to deal with lossy app-server delivery. After
|
|
/// `finish_mcp_startup()` or `finish_mcp_startup_after_lag()`, we briefly
|
|
/// ignore incoming updates so stale events from the just-finished round do not
|
|
/// reopen startup. While that guard is active we buffer updates for a possible
|
|
/// next round, and only reactivate once the buffered set is coherent enough to
|
|
/// treat as a fresh startup round.
|
|
fn update_mcp_startup_status(
|
|
&mut self,
|
|
server: String,
|
|
status: McpStartupStatus,
|
|
complete_when_settled: bool,
|
|
) {
|
|
let mut activated_pending_round = false;
|
|
let startup_status = if self.mcp_startup_ignore_updates_until_next_start {
|
|
// Ignore-mode buffers the next plausible round so stale post-finish
|
|
// updates cannot immediately reopen startup. A fresh `Starting`
|
|
// update resets the buffer only if we have not already seen a
|
|
// pending-round `Starting`; this preserves valid interleavings like
|
|
// `alpha: Starting -> alpha: Ready -> beta: Starting`.
|
|
if matches!(status, McpStartupStatus::Starting)
|
|
&& !self.mcp_startup_pending_next_round_saw_starting
|
|
{
|
|
self.mcp_startup_pending_next_round.clear();
|
|
self.mcp_startup_allow_terminal_only_next_round = false;
|
|
}
|
|
self.mcp_startup_pending_next_round_saw_starting |=
|
|
matches!(status, McpStartupStatus::Starting);
|
|
self.mcp_startup_pending_next_round.insert(server, status);
|
|
let Some(expected_servers) = &self.mcp_startup_expected_servers else {
|
|
return;
|
|
};
|
|
let saw_full_round = expected_servers.is_empty()
|
|
|| expected_servers
|
|
.iter()
|
|
.all(|name| self.mcp_startup_pending_next_round.contains_key(name));
|
|
let saw_starting = self
|
|
.mcp_startup_pending_next_round
|
|
.values()
|
|
.any(|state| matches!(state, McpStartupStatus::Starting));
|
|
if !(saw_full_round
|
|
&& (saw_starting || self.mcp_startup_allow_terminal_only_next_round))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// The buffered map now looks like a complete next round, so promote it
|
|
// to the active round and resume normal completion tracking.
|
|
self.mcp_startup_ignore_updates_until_next_start = false;
|
|
self.mcp_startup_allow_terminal_only_next_round = false;
|
|
self.mcp_startup_pending_next_round_saw_starting = false;
|
|
activated_pending_round = true;
|
|
std::mem::take(&mut self.mcp_startup_pending_next_round)
|
|
} else {
|
|
// Normal path: fold the update into the active round and surface
|
|
// per-server failures immediately.
|
|
let mut startup_status = self.mcp_startup_status.take().unwrap_or_default();
|
|
if let McpStartupStatus::Failed { error } = &status {
|
|
self.on_warning(error);
|
|
}
|
|
startup_status.insert(server, status);
|
|
startup_status
|
|
};
|
|
if activated_pending_round {
|
|
// A promoted buffered round may already contain terminal failures.
|
|
for state in startup_status.values() {
|
|
if let McpStartupStatus::Failed { error } = state {
|
|
self.on_warning(error);
|
|
}
|
|
}
|
|
}
|
|
self.mcp_startup_status = Some(startup_status);
|
|
self.update_task_running_state();
|
|
|
|
// App-server-backed startup completes when every expected server has
|
|
// reported a non-Starting status. Lag handling can force an earlier
|
|
// settle via `finish_mcp_startup_after_lag()`.
|
|
if complete_when_settled
|
|
&& let Some(current) = &self.mcp_startup_status
|
|
&& let Some(expected_servers) = &self.mcp_startup_expected_servers
|
|
&& !current.is_empty()
|
|
&& expected_servers
|
|
.iter()
|
|
.all(|name| current.contains_key(name))
|
|
&& current
|
|
.values()
|
|
.all(|state| !matches!(state, McpStartupStatus::Starting))
|
|
{
|
|
let mut failed = Vec::new();
|
|
let mut cancelled = Vec::new();
|
|
for (name, state) in current {
|
|
match state {
|
|
McpStartupStatus::Ready => {}
|
|
McpStartupStatus::Failed { .. } => failed.push(name.clone()),
|
|
McpStartupStatus::Cancelled => cancelled.push(name.clone()),
|
|
McpStartupStatus::Starting => {}
|
|
}
|
|
}
|
|
failed.sort();
|
|
cancelled.sort();
|
|
self.finish_mcp_startup(failed, cancelled);
|
|
return;
|
|
}
|
|
if let Some(current) = &self.mcp_startup_status {
|
|
// Otherwise keep the status header focused on the remaining
|
|
// in-progress servers for the active round.
|
|
let total = current.len();
|
|
let mut starting: Vec<_> = current
|
|
.iter()
|
|
.filter_map(|(name, state)| {
|
|
if matches!(state, McpStartupStatus::Starting) {
|
|
Some(name)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
starting.sort();
|
|
if let Some(first) = starting.first() {
|
|
let completed = total.saturating_sub(starting.len());
|
|
let max_to_show = 3;
|
|
let mut to_show: Vec<String> = starting
|
|
.iter()
|
|
.take(max_to_show)
|
|
.map(ToString::to_string)
|
|
.collect();
|
|
if starting.len() > max_to_show {
|
|
to_show.push("…".to_string());
|
|
}
|
|
let header = if total > 1 {
|
|
format!(
|
|
"{MCP_STARTUP_MULTI_HEADER_PREFIX} ({completed}/{total}): {}",
|
|
to_show.join(", ")
|
|
)
|
|
} else {
|
|
format!("{MCP_STARTUP_SINGLE_HEADER_PREFIX} {first}")
|
|
};
|
|
self.set_status_header(header);
|
|
}
|
|
}
|
|
self.request_redraw();
|
|
}
|
|
|
|
pub(crate) fn set_mcp_startup_expected_servers<I>(&mut self, server_names: I)
|
|
where
|
|
I: IntoIterator<Item = String>,
|
|
{
|
|
self.mcp_startup_expected_servers = Some(server_names.into_iter().collect());
|
|
}
|
|
|
|
pub(super) fn finish_mcp_startup(&mut self, failed: Vec<String>, cancelled: Vec<String>) {
|
|
if !cancelled.is_empty() {
|
|
self.on_warning(format!(
|
|
"MCP startup interrupted. The following servers were not initialized: {}",
|
|
cancelled.join(", ")
|
|
));
|
|
}
|
|
let mut parts = Vec::new();
|
|
if !failed.is_empty() {
|
|
parts.push(format!("failed: {}", failed.join(", ")));
|
|
}
|
|
if !parts.is_empty() {
|
|
self.on_warning(format!("MCP startup incomplete ({})", parts.join("; ")));
|
|
}
|
|
|
|
let mcp_startup_owned_status = self.status_header_is_mcp_startup_owned();
|
|
self.mcp_startup_status = None;
|
|
self.mcp_startup_ignore_updates_until_next_start = true;
|
|
self.mcp_startup_allow_terminal_only_next_round = false;
|
|
self.mcp_startup_pending_next_round.clear();
|
|
self.mcp_startup_pending_next_round_saw_starting = false;
|
|
self.update_task_running_state();
|
|
if self.bottom_pane.is_task_running() && mcp_startup_owned_status {
|
|
self.restore_reasoning_status_header();
|
|
}
|
|
self.maybe_send_next_queued_input();
|
|
self.request_redraw();
|
|
}
|
|
|
|
pub(crate) fn finish_mcp_startup_after_lag(&mut self) {
|
|
if self.mcp_startup_ignore_updates_until_next_start {
|
|
if self.mcp_startup_pending_next_round.is_empty() {
|
|
self.mcp_startup_pending_next_round_saw_starting = false;
|
|
}
|
|
self.mcp_startup_allow_terminal_only_next_round = true;
|
|
}
|
|
|
|
let Some(current) = &self.mcp_startup_status else {
|
|
return;
|
|
};
|
|
|
|
let mut failed = Vec::new();
|
|
let mut cancelled = Vec::new();
|
|
|
|
let mut server_names: BTreeSet<String> = current.keys().cloned().collect();
|
|
if let Some(expected_servers) = &self.mcp_startup_expected_servers {
|
|
server_names.extend(expected_servers.iter().cloned());
|
|
}
|
|
|
|
for name in server_names {
|
|
match current.get(&name) {
|
|
Some(McpStartupStatus::Ready) => {}
|
|
Some(McpStartupStatus::Failed { .. }) => failed.push(name),
|
|
Some(McpStartupStatus::Cancelled | McpStartupStatus::Starting) | None => {
|
|
cancelled.push(name);
|
|
}
|
|
}
|
|
}
|
|
|
|
failed.sort();
|
|
failed.dedup();
|
|
cancelled.sort();
|
|
cancelled.dedup();
|
|
self.finish_mcp_startup(failed, cancelled);
|
|
}
|
|
|
|
pub(super) fn status_header_is_mcp_startup_owned(&self) -> bool {
|
|
self.status_state
|
|
.current_status
|
|
.header
|
|
.starts_with(MCP_STARTUP_SINGLE_HEADER_PREFIX)
|
|
|| self
|
|
.status_state
|
|
.current_status
|
|
.header
|
|
.starts_with(MCP_STARTUP_MULTI_HEADER_PREFIX)
|
|
}
|
|
|
|
pub(super) fn on_mcp_server_status_updated(
|
|
&mut self,
|
|
notification: McpServerStatusUpdatedNotification,
|
|
) {
|
|
let status = match notification.status {
|
|
McpServerStartupState::Starting => McpStartupStatus::Starting,
|
|
McpServerStartupState::Ready => McpStartupStatus::Ready,
|
|
McpServerStartupState::Failed => McpStartupStatus::Failed {
|
|
error: notification.error.unwrap_or_else(|| {
|
|
format!("MCP client for `{}` failed to start", notification.name)
|
|
}),
|
|
},
|
|
McpServerStartupState::Cancelled => McpStartupStatus::Cancelled,
|
|
};
|
|
self.update_mcp_startup_status(
|
|
notification.name,
|
|
status,
|
|
/*complete_when_settled*/ true,
|
|
);
|
|
}
|
|
}
|