aboutsummaryrefslogtreecommitdiff
path: root/src/detect
diff options
context:
space:
mode:
authorakbash <akbash@herdr.dev>2026-08-25 01:08:36 +0300
committerGitHub <noreply@github.com>2026-08-25 01:08:36 +0300
commitcafd9e5dd0e59ecc7551ae066c737f23573460a7 (patch)
tree82ff1bbf5703f06c906bc22a783a2f18366f73d1 /src/detect
parent7d56b4c51b046a5d477439bcea4512339f8841aa (diff)
fix: refresh shared agent manifest caches (#3204)
refs #2711 Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com>
Diffstat (limited to 'src/detect')
-rw-r--r--src/detect/manifest.rs30
-rw-r--r--src/detect/manifest_update.rs221
2 files changed, 247 insertions, 4 deletions
diff --git a/src/detect/manifest.rs b/src/detect/manifest.rs
index b9fe786a..5e226ece 100644
--- a/src/detect/manifest.rs
+++ b/src/detect/manifest.rs
@@ -284,6 +284,36 @@ pub(crate) fn reload_manifests() -> Vec<AgentManifestSummary> {
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::<Vec<_>>();
+ 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<ManifestCache> {
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 362dd906..4e165fe9 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<crate::events::AppEv
let result = check_and_update();
let status = match result {
Ok(output) => {
- 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<crate::events::AppEv
};
let _ = events.blocking_send(crate::events::AppEvent::AgentDetectionManifestsUpdated {
updated: Vec::new(),
+ activated: Vec::new(),
status,
});
}
+fn agents_needing_cache_reload(output: &ManifestUpdateOutput) -> Vec<Agent> {
+ let loaded = super::manifest::manifest_summaries();
+ let mut activated = output
+ .updated
+ .iter()
+ .map(|commit| commit.agent)
+ .collect::<Vec<_>>();
+
+ 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<Agent>,
pub(crate) updated: Vec<ManifestUpdateCommit>,
pub(crate) status: ManifestUpdateStatus,
}
@@ -211,6 +244,7 @@ fn check_and_update_from_url(url: &str) -> Result<ManifestUpdateOutput, String>
status.last_check_unix = Some(check_time);
status.last_result = Some("checked".to_string());
+ let checked = catalog.iter().map(|entry| entry.agent).collect::<Vec<_>>();
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<ManifestUpdateOutput, String>
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"
@@ -691,6 +733,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"), &current).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"), &current_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", || {
let current = remote_manifest("9999.01.01.1", "current");