From 00b37ec6a0c205158f269ddfdf83257b9c6c3aa5 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 7 Jul 2026 21:33:21 -0600 Subject: [PATCH] fix(desktop): hash spawn config as the env receives it, not raw record fields The restart-required badge compared raw record values while the spawn env gets normalized ones, so three edits badged running agents that a restart would not change: - respond_to_allowlist: spawn normalizes (trim/lowercase/dedup) and only sets BUZZ_ACP_RESPOND_TO_ALLOWLIST in allowlist mode; the hash digested the raw list in every mode. Now hashed normalized, and only when respond_to == allowlist. - max_turn_duration_seconds: spawn fills the default into BUZZ_ACP_MAX_TURN_DURATION, so None and an explicit default are the same spawned value. Now hashed with the default filled in. - mcp_toolsets: same default-fallback (BUZZ_TOOLSETS); now hashed through the new DEFAULT_MCP_TOOLSETS constant, which also replaces the string literal in the spawn path so the two can't drift. Follow-up to #1602 (parked behind the spawn_hash persona-resolution rewrite that landed in #1618). Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../src-tauri/src/managed_agents/runtime.rs | 2 +- .../src/managed_agents/spawn_hash.rs | 26 ++++- .../src/managed_agents/spawn_hash/tests.rs | 95 +++++++++++++++++++ desktop/src-tauri/src/managed_agents/types.rs | 3 + 4 files changed, 122 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 841128b708..33e03edd9c 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1752,7 +1752,7 @@ pub fn spawn_agent_child( if let Some(toolsets) = &record.mcp_toolsets { command.env("BUZZ_TOOLSETS", toolsets); } else { - command.env("BUZZ_TOOLSETS", "default,canvas,forums,dms,media"); + command.env("BUZZ_TOOLSETS", super::types::DEFAULT_MCP_TOOLSETS); } command.env_remove("BUZZ_ACP_PRIVATE_KEY"); command.env_remove("BUZZ_ACP_API_TOKEN"); diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash.rs b/desktop/src-tauri/src/managed_agents/spawn_hash.rs index 527275a12b..3fbe7dd320 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash.rs @@ -92,11 +92,31 @@ pub(crate) fn spawn_config_hash( record.provider.hash(&mut hasher); record.auth_tag.hash(&mut hasher); record.respond_to.as_str().hash(&mut hasher); - record.respond_to_allowlist.hash(&mut hasher); + // The allowlist is hashed as the env receives it: spawn sets + // BUZZ_ACP_RESPOND_TO_ALLOWLIST only in allowlist mode, and normalized + // (trim/lowercase/dedup via `validate_respond_to_allowlist`) — so edits + // that don't survive normalization, or edits while another mode is + // active, must not badge. A list spawn would reject hashes raw: the + // stamped hash comes from a successful spawn, so any invalid edit + // correctly compares unequal. + if record.respond_to == super::types::RespondTo::Allowlist { + super::types::validate_respond_to_allowlist(&record.respond_to_allowlist) + .unwrap_or_else(|_| record.respond_to_allowlist.clone()) + .hash(&mut hasher); + } record.idle_timeout_seconds.hash(&mut hasher); - record.max_turn_duration_seconds.hash(&mut hasher); + // Spawn writes BUZZ_ACP_MAX_TURN_DURATION and BUZZ_TOOLSETS with defaults + // filled in, so None and an explicit default are the same spawned value. + record + .max_turn_duration_seconds + .unwrap_or(super::types::DEFAULT_AGENT_MAX_TURN_DURATION_SECONDS) + .hash(&mut hasher); record.parallelism.hash(&mut hasher); - record.mcp_toolsets.hash(&mut hasher); + record + .mcp_toolsets + .as_deref() + .unwrap_or(super::types::DEFAULT_MCP_TOOLSETS) + .hash(&mut hasher); record.persona_team_dir.hash(&mut hasher); record.persona_name_in_team.hash(&mut hasher); diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs index 12aade9a3b..ce46e639db 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::managed_agents::types::RespondTo; use std::collections::BTreeMap; fn record() -> ManagedAgentRecord { @@ -183,6 +184,7 @@ fn workspace_relay_change_ignored_for_pinned_record_relay() { fn respond_to_allowlist_edit_changes_hash() { let rec = record(); let mut edited = record(); + edited.respond_to = RespondTo::Allowlist; edited.respond_to_allowlist = vec!["a".repeat(64)]; assert_ne!( spawn_config_hash(&rec, &[], "wss://ws.example"), @@ -190,6 +192,99 @@ fn respond_to_allowlist_edit_changes_hash() { ); } +#[test] +fn allowlist_ignored_when_mode_is_not_allowlist() { + // Spawn only sets BUZZ_ACP_RESPOND_TO_ALLOWLIST in allowlist mode, so + // editing the (dormant) list under owner-only must not badge. + let rec = record(); + let mut edited = record(); + edited.respond_to_allowlist = vec!["a".repeat(64)]; + assert_eq!( + spawn_config_hash(&rec, &[], "wss://ws.example"), + spawn_config_hash(&edited, &[], "wss://ws.example") + ); +} + +#[test] +fn allowlist_normalization_equivalent_edits_do_not_change_hash() { + // The env receives the normalized list (trim/lowercase/dedup), so edits + // that normalize to the same value must not badge. + let mut rec = record(); + rec.respond_to = RespondTo::Allowlist; + rec.respond_to_allowlist = vec!["a".repeat(64)]; + let mut edited = rec.clone(); + edited.respond_to_allowlist = vec![ + format!(" {} ", "A".repeat(64)), // whitespace + case + "a".repeat(64), // duplicate + ]; + assert_eq!( + spawn_config_hash(&rec, &[], "wss://ws.example"), + spawn_config_hash(&edited, &[], "wss://ws.example") + ); +} + +#[test] +fn allowlist_content_edit_still_changes_hash() { + let mut rec = record(); + rec.respond_to = RespondTo::Allowlist; + rec.respond_to_allowlist = vec!["a".repeat(64)]; + let mut edited = rec.clone(); + edited.respond_to_allowlist = vec!["b".repeat(64)]; + assert_ne!( + spawn_config_hash(&rec, &[], "wss://ws.example"), + spawn_config_hash(&edited, &[], "wss://ws.example") + ); +} + +#[test] +fn explicit_default_max_turn_duration_does_not_change_hash() { + // Spawn writes BUZZ_ACP_MAX_TURN_DURATION with the default filled in, so + // None → Some(default) is the same spawned value and must not badge. + let rec = record(); + let mut edited = record(); + edited.max_turn_duration_seconds = + Some(crate::managed_agents::types::DEFAULT_AGENT_MAX_TURN_DURATION_SECONDS); + assert_eq!( + spawn_config_hash(&rec, &[], "wss://ws.example"), + spawn_config_hash(&edited, &[], "wss://ws.example") + ); +} + +#[test] +fn non_default_max_turn_duration_changes_hash() { + let rec = record(); + let mut edited = record(); + edited.max_turn_duration_seconds = Some(42); + assert_ne!( + spawn_config_hash(&rec, &[], "wss://ws.example"), + spawn_config_hash(&edited, &[], "wss://ws.example") + ); +} + +#[test] +fn explicit_default_toolsets_do_not_change_hash() { + // Spawn falls BUZZ_TOOLSETS back to the default set, so None → an + // explicit copy of the default is the same spawned value. + let rec = record(); + let mut edited = record(); + edited.mcp_toolsets = Some(crate::managed_agents::types::DEFAULT_MCP_TOOLSETS.to_string()); + assert_eq!( + spawn_config_hash(&rec, &[], "wss://ws.example"), + spawn_config_hash(&edited, &[], "wss://ws.example") + ); +} + +#[test] +fn non_default_toolsets_change_hash() { + let rec = record(); + let mut edited = record(); + edited.mcp_toolsets = Some("default,canvas".to_string()); + assert_ne!( + spawn_config_hash(&rec, &[], "wss://ws.example"), + spawn_config_hash(&edited, &[], "wss://ws.example") + ); +} + #[test] fn non_spawn_bookkeeping_fields_do_not_change_hash() { // updated_at / runtime_pid / last_* are lifecycle bookkeeping, not spawn diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 8eb905240c..66909b33dd 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -768,6 +768,9 @@ pub const DEFAULT_AGENT_TURN_TIMEOUT_SECONDS: u64 = 320; /// 1 hour — absolute wall-clock safety cap per turn. pub const DEFAULT_AGENT_MAX_TURN_DURATION_SECONDS: u64 = 3600; pub const DEFAULT_AGENT_PARALLELISM: u32 = 24; +/// Toolsets injected as `BUZZ_TOOLSETS` when the record doesn't pin its own — +/// single source of truth for the spawn env and the spawn-config hash. +pub const DEFAULT_MCP_TOOLSETS: &str = "default,canvas,forums,dms,media"; fn default_agent_parallelism() -> u32 { DEFAULT_AGENT_PARALLELISM