diff --git a/src/app/actions.rs b/src/app/actions.rs index d6e264f56f..d608e3d3c0 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -2746,7 +2746,9 @@ impl AppState { } Vec::new() } - AppEvent::AgentDetectionManifestsUpdated { updated, status } => { + AppEvent::AgentDetectionManifestsUpdated { + updated, status, .. + } => { self.agent_manifest_update_status = status; self.refresh_agent_manifest_summaries(); if !updated.is_empty() @@ -5785,6 +5787,7 @@ mod tests { version: crate::detect::manifest_update::ManifestVersion::parse("2026.06.10.1") .unwrap(), }], + activated: Vec::new(), status, }); diff --git a/src/app/api.rs b/src/app/api.rs index 92d9596fb7..5d347cf7d9 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -300,8 +300,8 @@ impl App { None }; let manifest_update_agents = - if let AppEvent::AgentDetectionManifestsUpdated { updated, .. } = &ev { - Some(updated.iter().map(|item| item.agent).collect::>()) + if let AppEvent::AgentDetectionManifestsUpdated { activated, .. } = &ev { + Some(activated.clone()) } else { None }; @@ -1423,7 +1423,7 @@ mod tests { } #[tokio::test] - async fn manifest_update_event_resets_matching_agent_detection_runtime() { + async fn manifest_activation_event_resets_matching_agent_detection_runtime() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), @@ -1448,11 +1448,8 @@ mod tests { app.terminal_runtimes.insert(terminal_id, runtime); app.handle_internal_event(AppEvent::AgentDetectionManifestsUpdated { - updated: vec![crate::detect::manifest_update::ManifestUpdateCommit { - agent: Agent::Codex, - version: crate::detect::manifest_update::ManifestVersion::parse("2026.06.10.1") - .unwrap(), - }], + updated: Vec::new(), + activated: vec![Agent::Codex], status: crate::detect::manifest_update::ManifestUpdateStatus::default(), }); diff --git a/src/detect/manifest.rs b/src/detect/manifest.rs index ed3de50787..2b9ae76dc8 100644 --- a/src/detect/manifest.rs +++ b/src/detect/manifest.rs @@ -283,6 +283,36 @@ pub(crate) fn reload_manifests() -> Vec { summaries } +pub(crate) fn reload_manifests_for_agents(agents: &[Agent]) { + if agents.is_empty() { + return; + } + + let _reload_guard = MANIFEST_RELOAD_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let lock = manifest_cache(); + let replacements = Agent::SCREEN_MANIFEST_AGENTS + .into_iter() + .filter(|agent| agents.contains(agent)) + .map(|agent| (agent, load_manifest_uncached(agent))) + .collect::>(); + let mut cache = match lock.write() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + for (agent, replacement) in replacements { + if let Some((_, loaded)) = cache + .manifests + .iter_mut() + .find(|(cached_agent, _)| *cached_agent == agent) + { + *loaded = replacement; + } + } +} + fn manifest_cache() -> &'static RwLock { MANIFEST_CACHE.get_or_init(|| RwLock::new(build_manifest_cache())) } diff --git a/src/detect/manifest_update.rs b/src/detect/manifest_update.rs index 362dd9060c..4e165fe918 100644 --- a/src/detect/manifest_update.rs +++ b/src/detect/manifest_update.rs @@ -169,11 +169,13 @@ pub(crate) fn auto_update(events: tokio::sync::mpsc::Sender { - if !output.updated.is_empty() { - super::manifest::reload_manifests(); + let activated = agents_needing_cache_reload(&output); + if !activated.is_empty() { + super::manifest::reload_manifests_for_agents(&activated); } let _ = events.blocking_send(crate::events::AppEvent::AgentDetectionManifestsUpdated { updated: output.updated, + activated, status: output.status, }); return; @@ -189,12 +191,43 @@ pub(crate) fn auto_update(events: tokio::sync::mpsc::Sender Vec { + let loaded = super::manifest::manifest_summaries(); + let mut activated = output + .updated + .iter() + .map(|commit| commit.agent) + .collect::>(); + + for agent in &output.checked { + let agent = *agent; + let Some(status) = output.status.agent_status(agent) else { + continue; + }; + if status.last_result != "current" && status.last_result != "updated" { + continue; + } + let loaded_version = loaded + .iter() + .find(|summary| summary.agent == agent) + .and_then(|summary| summary.cached_remote_version.as_deref()); + let disk_version = cached_remote_version(agent).map(|version| version.to_string()); + if loaded_version != disk_version.as_deref() && !activated.contains(&agent) { + activated.push(agent); + } + } + + activated +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ManifestUpdateOutput { + pub(crate) checked: Vec, pub(crate) updated: Vec, pub(crate) status: ManifestUpdateStatus, } @@ -211,6 +244,7 @@ fn check_and_update_from_url(url: &str) -> Result status.last_check_unix = Some(check_time); status.last_result = Some("checked".to_string()); + let checked = catalog.iter().map(|entry| entry.agent).collect::>(); let mut updated = Vec::new(); for entry in catalog { let agent_id = agent_label(entry.agent).to_string(); @@ -270,7 +304,11 @@ fn check_and_update_from_url(url: &str) -> Result tracing::warn!("failed to save agent detection manifest update status: {err}"); status.last_result = Some(format!("failed_to_save_status: {err}")); } - Ok(ManifestUpdateOutput { updated, status }) + Ok(ManifestUpdateOutput { + checked, + updated, + status, + }) } fn process_agent_manifest( @@ -550,9 +588,13 @@ fn now_nanos() -> u128 { mod tests { use super::*; fn remote_manifest(version: &str, contains: &str) -> String { + remote_manifest_for("codex", version, contains) + } + + fn remote_manifest_for(agent: &str, version: &str, contains: &str) -> String { format!( r#" -id = "codex" +id = "{agent}" version = "{version}" min_engine_version = 1 updated_at = "2026-06-10T12:00:00Z" @@ -690,6 +732,177 @@ path = "codex.toml" }); } + #[test] + fn auto_update_reloads_manifest_cache_when_remote_is_already_current() { + with_state_dir("auto-update-reloads-current-cache", || { + let initial = remote_manifest("9999.01.01.1", "initial-ready"); + process_agent_manifest(Agent::Codex, &initial, 1).unwrap(); + crate::detect::manifest::reload_manifests(); + + let old_catalog_url = std::env::var_os(CATALOG_URL_ENV); + let web_dir = std::env::temp_dir().join(format!( + "herdr-manifest-update-current-web-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&web_dir); + fs::create_dir_all(&web_dir).unwrap(); + fs::write( + web_dir.join("index.toml"), + r#" +schema_version = 1 + +[[agents]] +id = "codex" +path = "codex.toml" +"#, + ) + .unwrap(); + let current = remote_manifest("9999.01.01.2", "current-ready"); + fs::write(web_dir.join("codex.toml"), ¤t).unwrap(); + fs::write(remote_manifest_path(Agent::Codex), current).unwrap(); + std::env::set_var( + CATALOG_URL_ENV, + format!("file://{}", web_dir.join("index.toml").display()), + ); + + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + auto_update(tx); + + let event = rx.try_recv().expect("manifest update event"); + let crate::events::AppEvent::AgentDetectionManifestsUpdated { updated, .. } = event + else { + panic!("unexpected event"); + }; + assert!(updated.is_empty()); + + let explain = crate::detect::manifest::explain(Agent::Codex, "current-ready"); + assert_eq!(explain.state, crate::detect::AgentState::Idle); + assert_eq!(explain.manifest_version.as_deref(), Some("9999.01.01.2")); + assert_eq!( + explain.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("idle") + ); + + match old_catalog_url { + Some(value) => std::env::set_var(CATALOG_URL_ENV, value), + None => std::env::remove_var(CATALOG_URL_ENV), + } + let _ = fs::remove_dir_all(&web_dir); + }); + } + + #[test] + fn auto_update_does_not_reload_agents_whose_check_failed() { + with_state_dir("auto-update-skips-failed-agent", || { + let initial_codex = remote_manifest("9999.01.01.1", "codex-initial-ready"); + process_agent_manifest(Agent::Codex, &initial_codex, 1).unwrap(); + let initial_cursor = + remote_manifest_for("cursor", "9999.01.01.1", "cursor-initial-ready"); + process_agent_manifest(Agent::Cursor, &initial_cursor, 1).unwrap(); + crate::detect::manifest::reload_manifests(); + + let old_catalog_url = std::env::var_os(CATALOG_URL_ENV); + let web_dir = std::env::temp_dir().join(format!( + "herdr-manifest-update-partial-web-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&web_dir); + fs::create_dir_all(&web_dir).unwrap(); + fs::write( + web_dir.join("index.toml"), + r#" +schema_version = 1 + +[[agents]] +id = "codex" +path = "codex.toml" + +[[agents]] +id = "cursor" +path = "missing-cursor.toml" +"#, + ) + .unwrap(); + let current_codex = remote_manifest("9999.01.01.2", "codex-current-ready"); + fs::write(web_dir.join("codex.toml"), ¤t_codex).unwrap(); + fs::write(remote_manifest_path(Agent::Codex), current_codex).unwrap(); + fs::write( + remote_manifest_path(Agent::Cursor), + remote_manifest_for("cursor", "9999.01.01.2", "cursor-current-ready"), + ) + .unwrap(); + std::env::set_var( + CATALOG_URL_ENV, + format!("file://{}", web_dir.join("index.toml").display()), + ); + + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + auto_update(tx); + + let event = rx.try_recv().expect("manifest update event"); + let crate::events::AppEvent::AgentDetectionManifestsUpdated { + updated, activated, .. + } = event + else { + panic!("unexpected event"); + }; + assert!(updated.is_empty()); + assert_eq!(activated, vec![Agent::Codex]); + + let codex = crate::detect::manifest::explain(Agent::Codex, "codex-current-ready"); + assert_eq!(codex.manifest_version.as_deref(), Some("9999.01.01.2")); + assert_eq!( + codex.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("idle") + ); + let cursor = crate::detect::manifest::explain(Agent::Cursor, "cursor-initial-ready"); + assert_eq!(cursor.manifest_version.as_deref(), Some("9999.01.01.1")); + assert_eq!( + cursor.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("idle") + ); + + match old_catalog_url { + Some(value) => std::env::set_var(CATALOG_URL_ENV, value), + None => std::env::remove_var(CATALOG_URL_ENV), + } + let _ = fs::remove_dir_all(&web_dir); + }); + } + + #[test] + fn cache_reload_ignores_retained_status_for_unchecked_agent() { + with_state_dir("cache-reload-ignores-retained-status", || { + let initial = remote_manifest("9999.01.01.1", "initial-ready"); + process_agent_manifest(Agent::Codex, &initial, 1).unwrap(); + crate::detect::manifest::reload_manifests(); + fs::write( + remote_manifest_path(Agent::Codex), + remote_manifest("9999.01.01.2", "current-ready"), + ) + .unwrap(); + + let mut status = ManifestUpdateStatus::default(); + status.agents.insert( + "codex".to_string(), + AgentRemoteStatus { + cached_version: Some("9999.01.01.1".to_string()), + attempted_version: None, + last_checked_unix: Some(1), + last_result: "current".to_string(), + last_error: None, + }, + ); + let output = ManifestUpdateOutput { + checked: vec![Agent::Cursor], + updated: Vec::new(), + status, + }; + + assert!(agents_needing_cache_reload(&output).is_empty()); + }); + } + #[test] fn process_agent_manifest_rejects_downgrade_and_keeps_cached_manifest() { with_state_dir("reject-downgrade", || { diff --git a/src/events.rs b/src/events.rs index cc638e6018..cffcf03d8c 100644 --- a/src/events.rs +++ b/src/events.rs @@ -128,6 +128,7 @@ pub enum AppEvent { /// Remote agent detection manifest update check finished. AgentDetectionManifestsUpdated { updated: Vec, + activated: Vec, status: crate::detect::manifest_update::ManifestUpdateStatus, }, /// A pane child emitted one or more executable BEL characters.