Refresh runtimes for remote plugin state changes (#41396)

## Why

Enabling, disabling, or reinstalling a cached remote plugin can change its
effective MCP servers, hooks, and skills without materializing a new bundle.
These transitions need to invalidate plugin consumers just like bundle updates
and removals do.

## What changed

- Report a unified set of changed remote plugins and their affected
  capabilities for materializations, removals, and installed-state changes.
- Derive enablement and reinstall changes by comparing installed snapshots,
  while keeping materialization metadata separate for hook trust handling.
- Preserve removal notifications even when stale cache cleanup fails, and use
  all reported changes to refresh loaded plugin state and runtime consumers.

## Testing

Added coverage for cached plugin enablement, disablement, removal, and
reinstallation, including removal when cache cleanup cannot complete.

GitOrigin-RevId: 86d9c1356f7cf70b3c85effebc3e5986b931bdba
This commit is contained in:
Matthew Zeng
2026-08-28 19:12:34 +00:00
committed by copyberry
parent b836aecd4d
commit bb998aeb8e
7 changed files with 245 additions and 37 deletions
+65 -4
View File
@@ -57,7 +57,9 @@ use crate::remote::RecommendedPluginsMode;
use crate::remote::RemoteInstalledPlugin;
use crate::remote::RemoteInstalledPluginBundleSyncError;
use crate::remote::RemoteInstalledPluginBundleSyncOutcome;
use crate::remote::RemotePluginCapabilities;
use crate::remote::RemotePluginCatalogError;
use crate::remote::RemotePluginChange;
use crate::remote::RemotePluginMaterialization;
use crate::remote::RemotePluginScope;
use crate::remote::RemotePluginServiceConfig;
@@ -1502,12 +1504,12 @@ impl PluginsManager {
Ok((outcome, effective_plugins_changed)) => {
tracing::info!(
materialized_remote_plugins = ?outcome.materialized_remote_plugins,
removed_plugins = ?outcome.removed_plugins,
changed_plugins = ?outcome.changed_plugins,
failed_remote_plugin_ids = ?outcome.failed_remote_plugin_ids,
failed_materialization_remote_plugin_ids = ?outcome.failed_materialization_remote_plugin_ids,
"completed remote installed plugin bundle sync"
);
if (effective_plugins_changed || outcome.changed_local_cache())
if (effective_plugins_changed || !outcome.changed_plugins.is_empty())
&& let Some(on_effective_plugins_changed) = on_effective_plugins_changed
{
on_effective_plugins_changed(EffectivePluginsChange {
@@ -1578,13 +1580,72 @@ impl PluginsManager {
generation,
committed: false,
};
let previous_enabled = {
let cache = match self.remote_installed_plugins_cache.read() {
Ok(cache) => cache,
Err(err) => err.into_inner(),
};
cache.plugins.as_ref().map(|plugins| {
plugins
.iter()
.filter_map(|plugin| {
PluginId::new(plugin.name.clone(), plugin.marketplace_name.clone())
.ok()
.map(|plugin_id| (plugin_id, plugin.enabled))
})
.collect::<HashMap<_, _>>()
})
};
let previous_plugin_ids = previous_enabled
.iter()
.flat_map(HashMap::keys)
.cloned()
.collect::<Vec<_>>();
let result = crate::remote::sync_remote_installed_plugin_bundles_once_with_snapshot(
self.codex_home.clone(),
&remote_plugin_service_config(config),
auth,
&previous_plugin_ids,
)
.await?;
let outcome = result.outcome;
let mut outcome = result.outcome;
// The generation fence keeps this comparison on the snapshot replaced by this pass.
// In a known snapshot, absence means inactive: cached reinstalls need the same hints
// as re-enablement, without becoming materializations or triggering hook-trust writes.
if let Some(previous_enabled) = previous_enabled {
for plugin in &result.installed_plugins {
let plugin_id = PluginId::new(plugin.name.clone(), plugin.marketplace_name.clone())
.map_err(|err| RemotePluginCatalogError::UnexpectedResponse(err.to_string()))?;
if previous_enabled
.get(&plugin_id)
.copied()
.unwrap_or_default()
== plugin.enabled
{
continue;
}
let plugin_key = plugin_id.as_key();
if outcome
.changed_plugins
.iter()
.any(|change| change.plugin_id == plugin_key)
|| self.store.active_plugin_root(&plugin_id).is_none()
{
continue;
}
let mut capabilities = RemotePluginCapabilities::default();
capabilities
.include_active_bundle(&self.store, &plugin_id)
.await;
outcome.changed_plugins.push(RemotePluginChange {
plugin_id: plugin_key,
capabilities,
});
}
}
outcome
.changed_plugins
.sort_unstable_by(|left, right| left.plugin_id.cmp(&right.plugin_id));
let Some(effective_plugins_changed) = self.write_remote_installed_plugins_cache_snapshot(
generation,
result.installed_plugins,
@@ -1594,7 +1655,7 @@ impl PluginsManager {
return Err(RemoteInstalledPluginBundleSyncError::Superseded);
};
reconciliation.committed = true;
if !effective_plugins_changed && outcome.changed_local_cache() {
if !effective_plugins_changed && !outcome.changed_plugins.is_empty() {
self.clear_loaded_plugins_cache();
}
Ok((outcome, effective_plugins_changed))