Trace MCP refresh causes and plugin metadata changes (#60505)

## What changed

- Track and log `McpRefreshReason` values for runtime refreshes, including invalidations received during thread startup and forced refreshes. Restore claimed reasons when a refresh is cancelled before publication, while preserving concurrent invalidations.
- Correlate installed-plugin metadata invalidations with downstream MCP refreshes using a shared change ID. Log changed field names by plugin ID, additions, removals, and reordering without logging field values.
- Record whether background installed-plugin bundle updates were requested by startup, plugin listing, or installation.

## Testing

Add coverage for metadata change attribution across membership changes and reordering, and for retaining refresh causes across cancellation and concurrent invalidation.

GitOrigin-RevId: 3d4485ccfcc1d8788eb9909a060ac200c467af13
This commit is contained in:
Charlie Marsh
2026-11-10 02:45:30 +00:00
committed by copyberry
parent 0e6f153732
commit aa39ded153
28 changed files with 534 additions and 196 deletions
+1
View File
@@ -188,6 +188,7 @@ pub use thread_manager::AgentTreeShutdownFailureReason;
pub use thread_manager::AgentTreeShutdownReport;
pub use thread_manager::ForkSnapshot;
pub use thread_manager::InternalSessionParent;
pub use thread_manager::McpRefreshReason;
pub use thread_manager::NewThread;
pub use thread_manager::PromptCacheKey;
pub use thread_manager::StartThreadOptions;
+7 -2
View File
@@ -1,5 +1,6 @@
//! Publishes resolved MCP configuration and host timing rollouts for future turns.
use crate::McpRefreshReason;
use std::sync::Arc;
use crate::ConfigRefreshOutcome;
@@ -25,7 +26,7 @@ impl Session {
config.disable_mcp_enterprise_auth();
state.session_configuration.original_config_do_not_use = Arc::new(config);
self.services.mcp_runtime.invalidate_resource_caches();
self.mark_mcp_runtime_dirty();
self.mark_mcp_runtime_dirty(McpRefreshReason::EnterpriseAuthDisabled);
}
self.services.mcp_runtime.quiesce_ema().await;
self.schedule_mcp_prewarm();
@@ -192,7 +193,11 @@ impl Session {
if matches!(scope, RuntimeConfigRefresh::Mcp) {
self.services.mcp_runtime.invalidate_resource_caches();
}
self.mark_mcp_runtime_dirty();
self.mark_mcp_runtime_dirty(match scope {
RuntimeConfigRefresh::User => McpRefreshReason::UserConfigReloaded,
RuntimeConfigRefresh::Mcp => McpRefreshReason::McpConfigReloaded,
RuntimeConfigRefresh::UserFiles => McpRefreshReason::UserConfigFilesReloaded,
});
let new_config = notify_contributors
.then(|| self.build_effective_session_config(&state.session_configuration));
(previous_config, new_config)
+2 -1
View File
@@ -1,3 +1,4 @@
use crate::McpRefreshReason;
use std::collections::HashSet;
use std::sync::Arc;
@@ -369,7 +370,7 @@ impl Session {
state.session_configuration.environments = future;
if update_current {
// Invalidate MCP before installed configuration can wake a waiting turn.
self.mark_mcp_runtime_dirty();
self.mark_mcp_runtime_dirty(McpRefreshReason::CurrentEnvironmentsChanged);
self.services.turn_environments.update_selections(&current);
}
Ok(())
+2 -1
View File
@@ -1,4 +1,5 @@
use super::Submission;
use crate::McpRefreshReason;
use crate::WithTurnExtensionData;
use crate::realtime_conversation::handle_audio as handle_realtime_conversation_audio;
use crate::realtime_conversation::handle_close as handle_realtime_conversation_close;
@@ -260,7 +261,7 @@ pub async fn dynamic_tool_response(sess: &Arc<Session>, id: String, response: Dy
pub fn refresh_mcp_servers(sess: &Session) {
sess.services.mcp_runtime.reconnect_on_next_refresh();
sess.request_mcp_runtime_refresh();
sess.request_mcp_runtime_refresh(McpRefreshReason::ExplicitServerReconnect);
}
pub async fn reload_user_config(sess: &Arc<Session>) {
+21 -26
View File
@@ -1,5 +1,5 @@
use super::mcp_refresh::McpRefreshInvalidationGuard;
use super::*;
use crate::McpRefreshReason;
use crate::environment_selection::TurnEnvironmentSnapshot;
use crate::environment_selection::combine_selected_capability_roots;
use crate::mcp_tool_call::McpToolApprovalContext;
@@ -119,8 +119,11 @@ impl Session {
/*skill_turn*/ None,
)
.await;
if inventory_changed || discovery_recovered {
self.mark_mcp_runtime_dirty();
if inventory_changed {
self.mark_mcp_runtime_dirty(McpRefreshReason::ExecutorCapabilityInventoryChanged);
}
if discovery_recovered {
self.mark_mcp_runtime_dirty(McpRefreshReason::ExecutorCapabilityDiscoveryRecovered);
}
let mcp_projection =
self.services
@@ -199,7 +202,7 @@ impl Session {
&environment_selections,
&environments.ready_environment_handles(),
) {
self.mark_mcp_runtime_dirty();
self.mark_mcp_runtime_dirty(McpRefreshReason::EnvironmentBindingsChanged);
}
let auth = self.services.auth_manager.auth_cached();
if !self
@@ -207,15 +210,11 @@ impl Session {
.mcp_runtime
.current_auth_matches(auth.as_ref())
{
self.mark_mcp_runtime_dirty();
self.mark_mcp_runtime_dirty(McpRefreshReason::RuntimeAuthChanged);
}
if !self.mcp_refresh.claim() {
let Some(mut refresh_invalidation) = self.mcp_refresh.claim(self.thread_id) else {
return;
}
let mut refresh_invalidation = McpRefreshInvalidationGuard {
refresh: &self.mcp_refresh,
published: false,
};
let auth = self.services.auth_manager.auth().await;
// Capture after claiming the refresh so a new invalidation cannot be consumed
@@ -282,7 +281,7 @@ impl Session {
self: &Arc<Self>,
) -> anyhow::Result<codex_mcp::CodexAppsToolSnapshot> {
// Reconcile unchanged config so failed or closed clients can be replaced.
self.mark_mcp_runtime_dirty();
self.mark_mcp_runtime_dirty(McpRefreshReason::CodexAppsToolRefresh);
self.refresh_mcp_if_dirty().await;
let _refresh = self
.mcp_refresh
@@ -302,11 +301,9 @@ impl Session {
.await
.map_err(|_| anyhow::anyhow!("MCP runtime refresh semaphore closed"))?;
// This is forced work even when there was no pending invalidation.
self.mcp_refresh.claim();
let mut refresh_invalidation = McpRefreshInvalidationGuard {
refresh: &self.mcp_refresh,
published: false,
};
let mut refresh_invalidation = self
.mcp_refresh
.claim_forced(self.thread_id, McpRefreshReason::HardRefreshCodexAppsTools);
let auth = self.services.auth_manager.auth().await;
let environments = self.services.turn_environments.snapshot().await;
let environment_selections = environments.all_selections();
@@ -380,8 +377,8 @@ impl Session {
refreshed
}
pub(super) fn mark_mcp_runtime_dirty(&self) {
self.mcp_refresh.invalidate();
pub(super) fn mark_mcp_runtime_dirty(&self, reason: McpRefreshReason) {
self.mcp_refresh.invalidate(reason);
}
#[tracing::instrument(
@@ -404,7 +401,7 @@ impl Session {
.current_ready_selected_capability_roots()
!= ready_selected_capability_roots
{
self.mark_mcp_runtime_dirty();
self.mark_mcp_runtime_dirty(McpRefreshReason::SelectedCapabilityRootsChanged);
}
let recovered_oauth_servers =
@@ -423,7 +420,7 @@ impl Session {
.has_authentication_failed_servers(&recovered_oauth_servers)
.await
{
self.mark_mcp_runtime_dirty();
self.mark_mcp_runtime_dirty(McpRefreshReason::OAuthCredentialsRecovered);
}
self.refresh_mcp_if_dirty().await;
// Prewarm and other context-only captures must not start cloud discovery.
@@ -450,7 +447,7 @@ impl Session {
.await
{
// Preserve MCP updates if a later refresh is cancelled.
self.mark_mcp_runtime_dirty();
self.mark_mcp_runtime_dirty(McpRefreshReason::ExtensionCatalogChanged);
}
}
self.refresh_mcp_if_dirty().await;
@@ -861,11 +858,9 @@ impl Session {
state.session_configuration.original_config_do_not_use = Arc::new(config);
}
// Claim after config mutation, before capturing the state this forced refresh handles.
self.mcp_refresh.claim();
let mut refresh_invalidation = McpRefreshInvalidationGuard {
refresh: &self.mcp_refresh,
published: false,
};
let mut refresh_invalidation = self
.mcp_refresh
.claim_forced(self.thread_id, McpRefreshReason::SkillMcpDependenciesAdded);
let auth = self.services.auth_manager.auth().await;
let ready_selected_capability_roots = self
.services
+6 -5
View File
@@ -4,17 +4,18 @@
//! newest thread state; exact model steps remain the correctness path.
use super::*;
use crate::McpRefreshReason;
impl Session {
pub(crate) fn request_mcp_runtime_refresh(&self) {
pub(crate) fn request_mcp_runtime_refresh(&self, reason: McpRefreshReason) {
// Plugin changes can reuse connections but still change their skill resources.
self.services.mcp_runtime.invalidate_resource_caches();
self.request_mcp_runtime_reprojection();
self.request_mcp_runtime_reprojection(reason);
}
/// Reproject contributor state without invalidating cached MCP resources.
pub(crate) fn request_mcp_runtime_reprojection(&self) {
self.mark_mcp_runtime_dirty();
pub(crate) fn request_mcp_runtime_reprojection(&self, reason: McpRefreshReason) {
self.mark_mcp_runtime_dirty(reason);
self.schedule_mcp_prewarm();
}
@@ -47,7 +48,7 @@ impl Session {
break;
};
if auth_changed {
session.mark_mcp_runtime_dirty();
session.mark_mcp_runtime_dirty(McpRefreshReason::AuthChangeNotification);
}
tokio::select! {
biased;
+62 -11
View File
@@ -1,34 +1,78 @@
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use crate::McpRefreshReason;
use codex_protocol::ThreadId;
use std::collections::BTreeSet;
use std::sync::Mutex;
use tokio::sync::AcquireError;
use tokio::sync::Semaphore;
use tokio::sync::SemaphorePermit;
/// Owns MCP invalidation and the single gate used to publish runtime updates.
pub(super) struct McpRefresh {
pending: AtomicBool,
pending: Mutex<BTreeSet<McpRefreshReason>>,
gate: Semaphore,
}
impl McpRefresh {
pub(super) fn new() -> Self {
Self {
pending: AtomicBool::new(false),
pending: Mutex::default(),
gate: Semaphore::new(/*permits*/ 1),
}
}
pub(super) fn invalidate(&self) {
self.pending.store(true, Ordering::Release);
pub(super) fn invalidate(&self, reason: McpRefreshReason) {
self.pending
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(reason);
}
#[cfg(test)]
pub(super) fn is_pending(&self) -> bool {
self.pending.load(Ordering::Acquire)
!self
.pending
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_empty()
}
pub(super) fn claim(&self) -> bool {
self.pending.swap(false, Ordering::AcqRel)
/// Take only the causes known at the start of this refresh. Later invalidations
/// belong to the next publication, even if they have the same cause.
pub(super) fn claim(&self, thread_id: ThreadId) -> Option<McpRefreshInvalidationGuard<'_>> {
let reasons = self.take_pending();
(!reasons.is_empty()).then(|| self.start(thread_id, reasons))
}
pub(super) fn claim_forced(
&self,
thread_id: ThreadId,
reason: McpRefreshReason,
) -> McpRefreshInvalidationGuard<'_> {
let mut reasons = self.take_pending();
reasons.insert(reason);
self.start(thread_id, reasons)
}
pub(super) fn take_pending(&self) -> BTreeSet<McpRefreshReason> {
std::mem::take(
&mut *self
.pending
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
)
}
fn start(
&self,
thread_id: ThreadId,
reasons: BTreeSet<McpRefreshReason>,
) -> McpRefreshInvalidationGuard<'_> {
tracing::info!(%thread_id, ?reasons, "refreshing MCP runtime");
McpRefreshInvalidationGuard {
refresh: self,
reasons,
published: false,
}
}
#[tracing::instrument(name = "mcp.runtime.refresh_wait", skip_all)]
@@ -43,14 +87,21 @@ impl McpRefresh {
/// Restores a claimed refresh when its task is cancelled before publication.
pub(super) struct McpRefreshInvalidationGuard<'a> {
pub(super) refresh: &'a McpRefresh,
refresh: &'a McpRefresh,
reasons: BTreeSet<McpRefreshReason>,
pub(super) published: bool,
}
impl Drop for McpRefreshInvalidationGuard<'_> {
fn drop(&mut self) {
if !self.published {
self.refresh.invalidate();
let mut pending = self
.refresh
.pending
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
pending.append(&mut self.reasons);
pending.insert(McpRefreshReason::PreviousRefreshUnpublished);
}
}
}
+30 -21
View File
@@ -1,27 +1,36 @@
//! Checks that successful refreshes preserve newly pending work.
//! Verifies that refreshes consume their own causes and preserve concurrent work.
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn successful_manual_claim_consumes_only_preexisting_work() {
for initially_pending in [false, true] {
for invalidated_during_refresh in [false, true] {
let refresh = McpRefresh::new();
if initially_pending {
refresh.invalidate();
}
assert_eq!(refresh.claim(), initially_pending);
let mut guard = McpRefreshInvalidationGuard {
refresh: &refresh,
published: false,
};
if invalidated_during_refresh {
refresh.invalidate();
}
guard.published = true;
drop(guard);
assert_eq!(refresh.is_pending(), invalidated_during_refresh);
}
}
fn refresh_keeps_concurrent_invalidations_and_restores_unpublished_causes() {
let thread_id = ThreadId::new();
let refresh = McpRefresh::new();
refresh.invalidate(McpRefreshReason::McpConfigReloaded);
let claimed = refresh.claim(thread_id).expect("pending");
refresh.invalidate(McpRefreshReason::RuntimeAuthChanged);
drop(claimed);
let mut retry = refresh.claim(thread_id).expect("retry");
assert_eq!(
retry.reasons,
BTreeSet::from([
McpRefreshReason::RuntimeAuthChanged,
McpRefreshReason::McpConfigReloaded,
McpRefreshReason::PreviousRefreshUnpublished
])
);
refresh.invalidate(McpRefreshReason::RuntimeAuthChanged);
retry.published = true;
drop(retry);
let mut next = refresh.claim(thread_id).expect("new invalidation");
assert_eq!(
next.reasons,
BTreeSet::from([McpRefreshReason::RuntimeAuthChanged])
);
next.published = true;
drop(next);
assert!(refresh.claim(thread_id).is_none());
}
+7 -3
View File
@@ -1,5 +1,6 @@
pub(crate) mod startup;
use crate::McpRefreshReason;
use crate::PromptCacheKey;
use crate::WithTurnExtensionData;
use crate::runtime_auth::RuntimeAuthSlot;
@@ -2207,7 +2208,7 @@ impl Session {
!= updated.step_settings.service_tier;
let mcp_inputs_changed = self.mcp_inputs_differ(&state.session_configuration, &updated);
if mcp_inputs_changed {
self.mark_mcp_runtime_dirty();
self.mark_mcp_runtime_dirty(McpRefreshReason::SessionMcpInputsChanged);
}
// Save new environment defaults for future turns. The running turn keeps its own.
windows_sandbox_changed =
@@ -4378,8 +4379,11 @@ impl Session {
)
.await
};
if inventory_changed || discovery_recovered {
self.mark_mcp_runtime_dirty();
if inventory_changed {
self.mark_mcp_runtime_dirty(McpRefreshReason::ExecutorCapabilityInventoryChanged);
}
if discovery_recovered {
self.mark_mcp_runtime_dirty(McpRefreshReason::ExecutorCapabilityDiscoveryRecovered);
}
let extension_data =
codex_extension_api::ExtensionData::new(turn_context.sub_id.clone());
@@ -1,5 +1,6 @@
//! Activates an admitted task's plugin selection without changing pending settings.
use crate::McpRefreshReason;
use std::sync::Arc;
use super::Session;
@@ -11,7 +12,7 @@ impl Session {
let mut state = self.state.lock().await;
if state.active_disabled_plugin_ids != turn_context.disabled_plugin_ids {
state.active_disabled_plugin_ids = turn_context.disabled_plugin_ids.clone();
self.mark_mcp_runtime_dirty();
self.mark_mcp_runtime_dirty(McpRefreshReason::DisabledPluginSelectionChanged);
}
Arc::clone(&state.session_configuration.original_config_do_not_use)
};
@@ -1,4 +1,5 @@
use super::*;
use crate::McpRefreshReason;
use crate::agents_md_manager::AgentsMdManager;
use crate::agents_md_manager::SessionInstructions;
use crate::session::Submission;
@@ -612,7 +613,7 @@ async fn submitted_sparse_updates_preserve_captured_steps_and_ordering() {
.acquire()
.await
.expect("MCP refresh lock");
session.mark_mcp_runtime_dirty();
session.mark_mcp_runtime_dirty(McpRefreshReason::ExplicitInvalidation);
let capture_cancel = CancellationToken::new();
let mut during = Box::pin(tokio::task::unconstrained(
session.capture_step_context(Arc::clone(&turn), &capture_cancel),
+8 -7
View File
@@ -1,3 +1,4 @@
use crate::McpRefreshReason;
use crate::agent::LocalAgentControl;
use codex_protocol::ContextWindowId;
#[path = "compaction_replay_tests.rs"]
@@ -2329,7 +2330,7 @@ async fn refresh_config_rejects_stale_owner_without_effects(scope: RuntimeConfig
crate::ConfigRefreshOutcome::Published
);
let current_owner = session.get_config().await;
assert!(session.mcp_refresh.claim());
assert!(!session.mcp_refresh.take_pending().is_empty());
assert_eq!(
session
@@ -7073,7 +7074,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
"turn_id".to_string(),
skills_snapshot,
);
session.mark_mcp_runtime_dirty();
session.mark_mcp_runtime_dirty(McpRefreshReason::ExplicitInvalidation);
crate::guardian::test_host::install(&session, &turn_context.config);
(session, turn_context)
}
@@ -9417,7 +9418,7 @@ where
"turn_id".to_string(),
skills_snapshot,
));
session.mark_mcp_runtime_dirty();
session.mark_mcp_runtime_dirty(McpRefreshReason::ExplicitInvalidation);
crate::guardian::test_host::install(&session, &turn_context.config);
(session, turn_context, rx_event)
}
@@ -9604,7 +9605,7 @@ async fn refresh_mcp_servers_uses_latest_state_for_existing_turns() {
.expect("enable secret auth storage");
state.session_configuration.original_config_do_not_use = Arc::new(config);
}
session.mark_mcp_runtime_dirty();
session.mark_mcp_runtime_dirty(McpRefreshReason::ExplicitInvalidation);
let next_turn = session.new_default_turn().await;
let new_step = session
@@ -9774,7 +9775,7 @@ async fn mcp_elicitation_reviewer_uses_active_reviewer_and_latest_runtime_policy
.spawn_task(Arc::clone(&old_turn), Vec::new(), task)
.await;
session.mark_mcp_runtime_dirty();
session.mark_mcp_runtime_dirty(McpRefreshReason::ExplicitInvalidation);
session.refresh_mcp_if_dirty().await;
if live_update {
assert_eq!(
@@ -9990,7 +9991,7 @@ async fn mcp_elicitation_reviewer_is_reused_across_runtime_refreshes() {
let session = Arc::new(session);
let previous = session.mcp_elicitation_reviewer();
session.mark_mcp_runtime_dirty();
session.mark_mcp_runtime_dirty(McpRefreshReason::ExplicitInvalidation);
session.refresh_mcp_if_dirty().await;
assert!(Arc::ptr_eq(&previous, &session.mcp_elicitation_reviewer()));
@@ -10336,7 +10337,7 @@ async fn step_context_keeps_its_mcp_runtime_for_tools() -> anyhow::Result<()> {
);
assert_ne!(step_context.mcp.config().plugins, current_config.plugins);
session.mark_mcp_runtime_dirty();
session.mark_mcp_runtime_dirty(McpRefreshReason::ExplicitInvalidation);
session.refresh_mcp_if_dirty().await;
let current = session
.services
@@ -616,7 +616,7 @@ async fn automatic_admission_rechecks_plan_mode_without_committing_sparse_settin
.acquire()
.await
.expect("acquire MCP refresh gate");
session.mcp_refresh.claim();
session.mcp_refresh.take_pending();
assert!(!session.mcp_refresh.is_pending());
for (submission_id, prepared) in [
@@ -1,6 +1,7 @@
//! Turn-start MCP reconciliation, discovery cancellation, and prewarm refresh boundaries.
use super::*;
use crate::McpRefreshReason;
use codex_mcp::McpResourceClient;
use codex_mcp::McpResourceClientCacheKey;
use pretty_assertions::assert_eq;
@@ -73,10 +74,10 @@ async fn turn_start_refreshes_dirty_mcp_reuses_clean_runtime_and_cancels_discove
.requires_mcp
.store(turn > 0, std::sync::atomic::Ordering::Relaxed);
if turn == 0 {
session.mark_mcp_runtime_dirty();
session.mark_mcp_runtime_dirty(McpRefreshReason::ExplicitInvalidation);
} else if turn == 3 {
session.services.mcp_runtime.reconnect_on_next_refresh();
session.request_mcp_runtime_refresh();
session.request_mcp_runtime_refresh(McpRefreshReason::ExplicitInvalidation);
}
session
.spawn_task(
+2 -1
View File
@@ -5,6 +5,7 @@ mod regular;
mod review;
mod user_shell;
use crate::McpRefreshReason;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
@@ -304,7 +305,7 @@ impl Session {
.enabled(Feature::ExecutorCapabilityDiscoveryV2)
{
// MCP contributors must project the new inventory even if their other inputs match.
self.request_mcp_runtime_reprojection();
self.request_mcp_runtime_reprojection(McpRefreshReason::TaskStartedExecutorDiscovery);
}
self.activate_plugin_selection(&turn_context).await;
// Inherited or recovered roots are applied before task start. Otherwise this
+4 -1
View File
@@ -1,3 +1,4 @@
use crate::McpRefreshReason;
use std::sync::Arc;
use codex_async_utils::OrCancelExt;
@@ -92,7 +93,9 @@ impl SessionTask for RegularTask {
.await;
// Even cancelled discovery may have cleared the previous account's catalog.
if prepares_mcp {
sess.request_mcp_runtime_reprojection();
sess.request_mcp_runtime_reprojection(
McpRefreshReason::TurnStartContributorPreparedMcp,
);
}
if preparation.is_err() {
return SessionStartupPrewarmResolution::Cancelled;
+69 -9
View File
@@ -263,6 +263,51 @@ enum ShutdownOutcome {
TimedOut,
}
type StartupMcpInvalidations = std::sync::Mutex<std::collections::BTreeSet<McpRefreshReason>>;
/// Identifies the cause of an MCP runtime publication in CLI diagnostics.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum McpRefreshReason {
AccountAuthReloaded,
AccountLoggedOut,
AccountLoginCompleted,
AccountPluginCacheChanged,
AccountWorkspaceSwitched,
AuthChangeNotification,
CodexAppsToolRefresh,
CurrentEnvironmentsChanged,
DisabledPluginSelectionChanged,
EffectivePluginsChanged,
EnterpriseAuthDisabled,
EnvironmentBindingsChanged,
ExecutorCapabilityDiscoveryRecovered,
ExecutorCapabilityInventoryChanged,
ExplicitInvalidation,
ExplicitServerReconnect,
ExtensionCatalogChanged,
HardRefreshCodexAppsTools,
MarketplaceUpgraded,
McpConfigReloaded,
McpOAuthLoginCompleted,
NonCuratedPluginCacheRefreshed,
OAuthCredentialsRecovered,
PluginInstalled,
PluginOAuthLoginCompleted,
PluginUninstalled,
PreviousRefreshUnpublished,
RemotePluginUninstalled,
RuntimeAuthChanged,
SelectedCapabilityRootsChanged,
SessionMcpInputsChanged,
SkillMcpDependenciesAdded,
TaskStartedExecutorDiscovery,
TurnStartContributorPreparedMcp,
UserConfigFilesReloaded,
UserConfigReloaded,
/// Links to the field-level diff logged when the installed plugin cache changed.
InstalledPluginMetadata(uuid::Uuid),
}
/// [`ThreadManager`] is responsible for creating threads and maintaining
/// them in memory.
pub struct ThreadManager {
@@ -545,7 +590,7 @@ pub(crate) struct ThreadManagerState {
model_catalog: model_catalog::ThreadModelCatalog,
git_root_discovery: Arc<GitRootDiscovery>,
environment_manager: Arc<EnvironmentManager>,
starting_mcp_runtimes: std::sync::Mutex<Vec<std::sync::Weak<AtomicBool>>>,
starting_mcp_runtimes: std::sync::Mutex<Vec<std::sync::Weak<StartupMcpInvalidations>>>,
skills_service: Arc<HostSkillsService>,
plugins_manager: Arc<PluginsManager>,
mcp_manager: Arc<McpManager>,
@@ -990,7 +1035,12 @@ impl ThreadManager {
/// Refreshes every loaded thread and marks threads that are still being created.
pub async fn invalidate_mcp_runtimes(&self) {
self.invalidate_starting_mcp_runtimes();
self.invalidate_mcp_runtimes_for(McpRefreshReason::ExplicitInvalidation)
.await;
}
pub async fn invalidate_mcp_runtimes_for(&self, reason: McpRefreshReason) {
self.invalidate_starting_mcp_runtimes(reason);
let threads = self
.state
.threads
@@ -1000,7 +1050,7 @@ impl ThreadManager {
.cloned()
.collect::<Vec<_>>();
for thread in threads {
thread.session.request_mcp_runtime_refresh();
thread.session.request_mcp_runtime_refresh(reason);
}
}
@@ -1020,7 +1070,7 @@ impl ThreadManager {
}
}
fn invalidate_starting_mcp_runtimes(&self) {
fn invalidate_starting_mcp_runtimes(&self, reason: McpRefreshReason) {
let mut starting = self
.state
.starting_mcp_runtimes
@@ -1030,7 +1080,10 @@ impl ThreadManager {
let Some(runtime) = runtime.upgrade() else {
return false;
};
runtime.store(true, Ordering::Release);
runtime
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(reason);
true
});
}
@@ -2702,14 +2755,14 @@ impl ThreadManagerState {
(Arc::clone(&self.extensions), Arc::clone(&self.mcp_manager))
};
let tracked_session_source = session_source.clone();
let source_changed_during_startup = Arc::new(AtomicBool::new(false));
let startup_invalidations = Arc::new(StartupMcpInvalidations::default());
{
let mut starting = self
.starting_mcp_runtimes
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
starting.retain(|runtime| runtime.strong_count() != 0);
starting.push(Arc::downgrade(&source_changed_during_startup));
starting.push(Arc::downgrade(&startup_invalidations));
}
let windows_sandbox_proxy_settings_mode = if matches!(
&session_source,
@@ -2907,8 +2960,15 @@ impl ThreadManagerState {
if matches!(ready_notification, ThreadReadyNotification::Emit) {
new_thread.thread.emit_thread_ready_lifecycle().await;
}
if source_changed_during_startup.load(Ordering::Acquire) {
new_thread.thread.session.request_mcp_runtime_refresh();
for reason in std::mem::take(
&mut *startup_invalidations
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
) {
new_thread
.thread
.session
.request_mcp_runtime_refresh(reason);
}
if is_resumed_thread {
new_thread.thread.emit_thread_resume_lifecycle().await;