Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/app/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -5785,6 +5787,7 @@ mod tests {
version: crate::detect::manifest_update::ManifestVersion::parse("2026.06.10.1")
.unwrap(),
}],
activated: Vec::new(),
status,
});

Expand Down
13 changes: 5 additions & 8 deletions src/app/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>())
if let AppEvent::AgentDetectionManifestsUpdated { activated, .. } = &ev {
Some(activated.clone())
} else {
None
};
Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
});

Expand Down
30 changes: 30 additions & 0 deletions src/detect/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,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()))
}
Expand Down
221 changes: 217 additions & 4 deletions src/detect/manifest_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
}
Expand All @@ -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();
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"), &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", || {
Expand Down
1 change: 1 addition & 0 deletions src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ pub enum AppEvent {
/// Remote agent detection manifest update check finished.
AgentDetectionManifestsUpdated {
updated: Vec<crate::detect::manifest_update::ManifestUpdateCommit>,
activated: Vec<crate::detect::Agent>,
status: crate::detect::manifest_update::ManifestUpdateStatus,
},
/// A pane child emitted one or more executable BEL characters.
Expand Down
Loading