From b3b669f40f7ce80b8a22cf6ccccb087843573a01 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Sun, 28 Jun 2026 16:48:07 -0400 Subject: [PATCH] Desktop: stop auto-healing agent kind:0 on start and restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop record was the authority for an agent's avatar: on every agent start and on app-launch restore, a fire-and-forget task called reconcile_agent_profile, which recomputed the avatar from the local ManagedAgentRecord and republished kind:0 whenever it diverged from the relay. That healed a self-set avatar back to the device default — the one identity field desktop still overwrote without an explicit user action. Remove the two non-explicit kind:0 writers (the start-path spawn in start_managed_agent and the restore loop in restore_managed_agents). Desktop now publishes kind:0 only on explicit user action — agent creation and rename/model-edit save, both of which call sync_managed_agent_profile directly and are untouched. With both call sites gone, the reconcile chain is orphaned, so it is deleted rather than left as dead code: reconcile_agent_profile, resolve_legacy_avatar, profile_needs_sync, the ProfileReconcileData struct, and the now-unused relay helpers query_agent_profile / AgentProfileInfo, plus their unit tests. One behavior change: reconcile_agent_profile also performed a one-shot legacy-avatar backfill for pre-PR-921 records with no stored avatar_url. That backfill was coupled to the unwanted background healer; preserving it would reintroduce an implicit background kind:0 writer. A legacy agent now backfills on the next explicit edit-save instead of silently on start. No relay changes. Agent signing keys remain in the keyring. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- desktop/src-tauri/src/commands/agents.rs | 207 +----------------- .../src-tauri/src/commands/agents_tests.rs | 100 --------- .../src-tauri/src/managed_agents/restore.rs | 53 ----- desktop/src-tauri/src/relay.rs | 52 ----- 4 files changed, 3 insertions(+), 409 deletions(-) diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 706045a32..83b0ed2cb 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -896,28 +896,6 @@ pub async fn create_managed_agent( }) } -/// Data needed for background profile reconciliation after agent start. -pub(crate) struct ProfileReconcileData { - pub(crate) private_key_nsec: String, - pub(crate) name: String, - pub(crate) relay_url: String, - /// Expected avatar URL for the published profile. `None` for legacy records - /// that predate the `avatar_url` field — these will be backfilled from the - /// relay's existing kind:0 profile on first reconciliation. - pub(crate) avatar_url: Option, - pub(crate) auth_tag: Option, - /// The agent's pubkey (hex). Needed to update the persisted record during - /// avatar backfill migration. - pub(crate) pubkey: String, - /// The agent's command (e.g. "goose"). Used as fallback when no profile - /// exists on the relay during avatar backfill. - pub(crate) agent_command: String, - /// Persona ID if this agent was created from a persona. Used during avatar - /// backfill to recover the correct avatar from the persona record when the - /// relay profile has been corrupted. - pub(crate) persona_id: Option, -} - #[tauri::command] pub async fn start_managed_agent( pubkey: String, @@ -937,8 +915,7 @@ pub async fn start_managed_agent( } // Collect backend info under lock; async preflight/spawn happens below. - // Also snapshot profile reconciliation data for the background task. - let (target, reconcile_data) = { + let target = { let _store_guard = state .managed_agents_store_lock .lock() @@ -955,28 +932,7 @@ pub async fn start_managed_agent( let record = find_managed_agent_mut(&mut records, &pubkey)?; - // Resolve the effective harness for the avatar-fallback derivation in - // profile reconcile (the create-time snapshot may be empty or stale for - // a persona-inherited harness). - let reconcile_personas = load_personas(&app).unwrap_or_default(); - let reconcile_effective_command = crate::managed_agents::effective_agent_command( - record.persona_id.as_deref(), - &reconcile_personas, - record.agent_command_override.as_deref(), - ); - - let reconcile = ProfileReconcileData { - private_key_nsec: record.private_key_nsec.clone(), - name: record.name.clone(), - relay_url: record.relay_url.clone(), - avatar_url: record.avatar_url.clone(), - auth_tag: record.auth_tag.clone(), - pubkey: record.pubkey.clone(), - agent_command: reconcile_effective_command, - persona_id: record.persona_id.clone(), - }; - - let target = if record.backend == BackendKind::Local { + if record.backend == BackendKind::Local { StartTarget::Local } else { StartTarget::Provider { @@ -984,9 +940,7 @@ pub async fn start_managed_agent( cached_binary_path: record.provider_binary_path.clone(), agent_json: build_deploy_payload(&state, record)?, } - }; - - (target, reconcile) + } }; let result = match target { @@ -1031,164 +985,9 @@ pub async fn start_managed_agent( )), }; - // ── Profile reconciliation (fire-and-forget) ──────────────────────────── - // On successful start, spawn a background task to ensure the agent's kind:0 - // profile is published on the relay. This self-heals cases where the initial - // profile sync at creation time failed silently. For legacy records (pre-PR-921) - // with no persisted avatar, this also backfills the avatar from the relay. - if result.is_ok() { - let reconcile_pubkey = pubkey.clone(); - let reconcile_app = app.clone(); - tauri::async_runtime::spawn(async move { - use tauri::Manager; - let state = reconcile_app.state::(); - if let Err(e) = - reconcile_agent_profile(&state, &reconcile_app, &reconcile_pubkey, &reconcile_data) - .await - { - eprintln!( - "buzz-desktop: profile reconciliation failed for agent {reconcile_pubkey}: {e}" - ); - } - }); - } - result } -/// Resolve the avatar to backfill for a legacy agent record (pre-PR-921, no -/// stored `avatar_url`). -/// -/// Priority: the persona's avatar wins, because the old reconciliation code -/// could have overwritten the relay's kind:0 `picture` with the command default -/// — making the relay an unreliable source for persona-backed agents. Only fall -/// back to the relay's `picture`, then the command icon, for agents with no -/// persona avatar to recover from. -fn resolve_legacy_avatar( - persona_avatar: Option, - relay_picture: Option, - agent_command: &str, -) -> String { - persona_avatar - .or(relay_picture) - .or_else(|| managed_agent_avatar_url(agent_command)) - .unwrap_or_default() -} - -/// Reconcile an agent's kind:0 profile on the relay. -/// -/// Queries the relay for the agent's existing profile and re-publishes if missing -/// or stale (display_name or picture mismatch). This is fire-and-forget — errors -/// are returned to the caller for logging but never block agent startup. -/// -/// For legacy records (pre-PR-921) where `avatar_url` is `None`, this function -/// backfills via `resolve_legacy_avatar` — preferring the persona record's avatar -/// over the relay's `picture`, since the old code may have corrupted the relay -/// profile — and persists the updated record. After backfill, normal -/// reconciliation proceeds. -/// -/// Query and publish target the relay returned by `effective_agent_relay_url` -/// for every agent regardless of backend: an explicit per-agent `relay_url` -/// wins, and a blank one falls back to the active workspace relay. This keeps -/// reconciliation following the session's relay for never-pinned agents while -/// honoring a deliberate pin wherever it points. -pub(crate) async fn reconcile_agent_profile( - state: &AppState, - app: &AppHandle, - agent_pubkey: &str, - data: &ProfileReconcileData, -) -> Result<(), String> { - use crate::relay::{query_agent_profile, sync_managed_agent_profile}; - - // An explicit per-agent relay wins; an empty one falls back to the active - // workspace relay. Resolved once and used for both the read and write-back. - let relay_url = crate::relay::effective_agent_relay_url( - &data.relay_url, - &relay_ws_url_with_override(state), - ); - - // Query the relay for the agent's existing kind:0 profile. - let existing = query_agent_profile(state, &relay_url, agent_pubkey).await?; - - // Resolve the expected avatar — backfilling for legacy records that have no - // stored avatar_url yet. - let expected_avatar = match data.avatar_url.as_deref() { - Some(url) => url.to_string(), - None => { - // Legacy record: the relay profile may have been corrupted by the - // old reconciliation code (it overwrote the persona avatar with the - // command default), so the persona record is the authoritative source. - let persona_avatar = data.persona_id.as_ref().and_then(|pid| { - load_personas(app) - .ok()? - .into_iter() - .find(|p| p.id == *pid)? - .avatar_url - }); - - let backfilled = resolve_legacy_avatar( - persona_avatar, - existing.as_ref().and_then(|info| info.picture.clone()), - &data.agent_command, - ); - - // Persist the backfilled avatar so this migration only runs once. - if !backfilled.is_empty() { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(app)?; - if let Some(record) = records.iter_mut().find(|r| r.pubkey == data.pubkey) { - record.avatar_url = Some(backfilled.clone()); - save_managed_agents(app, &records)?; - } - } - - backfilled - } - }; - - if expected_avatar.is_empty() { - return Ok(()); - } - - if !profile_needs_sync(existing.as_ref(), &data.name, Some(&expected_avatar)) { - return Ok(()); - } - - let agent_keys = Keys::parse(&data.private_key_nsec) - .map_err(|e| format!("failed to parse agent keys: {e}"))?; - - sync_managed_agent_profile( - state, - &relay_url, - &agent_keys, - &data.name, - Some(&expected_avatar), - data.auth_tag.as_deref(), - ) - .await -} - -/// Decide whether a published profile is missing or stale relative to the -/// expected name and avatar. A missing profile always needs sync; a present -/// one is stale when either the display name or picture diverges. -fn profile_needs_sync( - existing: Option<&crate::relay::AgentProfileInfo>, - expected_name: &str, - expected_avatar: Option<&str>, -) -> bool { - match existing { - None => true, - Some(info) => { - let name_matches = info.display_name.as_deref() == Some(expected_name); - let picture_matches = info.picture.as_deref() == expected_avatar; - !name_matches || !picture_matches - } - } -} - #[tauri::command] pub fn stop_managed_agent( pubkey: String, diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index f141c6ae5..9d4c8422d 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -69,103 +69,3 @@ fn created_avatar_uses_command_fallback_without_input_or_persona() { assert_eq!(resolved, managed_agent_avatar_url("goose")); } - -fn profile(name: Option<&str>, picture: Option<&str>) -> crate::relay::AgentProfileInfo { - crate::relay::AgentProfileInfo { - display_name: name.map(str::to_string), - picture: picture.map(str::to_string), - } -} - -#[test] -fn profile_needs_sync_when_missing() { - assert!(profile_needs_sync(None, "Duncan", Some("https://x/a.png"))); -} - -#[test] -fn profile_needs_sync_when_name_diverges() { - let existing = profile(Some("Stilgar"), Some("https://x/a.png")); - assert!(profile_needs_sync( - Some(&existing), - "Duncan", - Some("https://x/a.png") - )); -} - -#[test] -fn profile_needs_sync_when_picture_diverges() { - let existing = profile(Some("Duncan"), Some("https://x/old.png")); - assert!(profile_needs_sync( - Some(&existing), - "Duncan", - Some("https://x/new.png") - )); -} - -#[test] -fn profile_in_sync_when_name_and_picture_match() { - let existing = profile(Some("Duncan"), Some("https://x/a.png")); - assert!(!profile_needs_sync( - Some(&existing), - "Duncan", - Some("https://x/a.png") - )); -} - -#[test] -fn profile_in_sync_when_both_avatars_absent() { - let existing = profile(Some("Duncan"), None); - assert!(!profile_needs_sync(Some(&existing), "Duncan", None)); -} - -#[test] -fn profile_needs_sync_when_existing_name_is_none() { - let existing = profile(None, Some("https://x/a.png")); - assert!(profile_needs_sync( - Some(&existing), - "Duncan", - Some("https://x/a.png"), - )); -} - -#[test] -fn profile_needs_sync_when_expected_avatar_absent_but_published() { - let existing = profile(Some("Duncan"), Some("https://x/a.png")); - assert!(profile_needs_sync(Some(&existing), "Duncan", None)); -} - -#[test] -fn legacy_avatar_prefers_persona_over_corrupted_relay_picture() { - // The regression: the relay picture was overwritten with the command - // default. The persona avatar must win so the correct avatar is restored. - let resolved = resolve_legacy_avatar( - Some("https://x/persona.png".to_string()), - Some("https://x/default-icon.png".to_string()), - "goose", - ); - - assert_eq!(resolved, "https://x/persona.png"); -} - -#[test] -fn legacy_avatar_falls_back_to_relay_picture_without_persona() { - let resolved = resolve_legacy_avatar(None, Some("https://x/relay.png".to_string()), "goose"); - - assert_eq!(resolved, "https://x/relay.png"); -} - -#[test] -fn legacy_avatar_falls_back_to_command_icon_when_no_persona_or_relay() { - use crate::managed_agents::managed_agent_avatar_url; - - let resolved = resolve_legacy_avatar(None, None, "goose"); - - assert_eq!(resolved, managed_agent_avatar_url("goose").unwrap()); -} - -#[test] -fn legacy_avatar_empty_when_nothing_resolves() { - let resolved = resolve_legacy_avatar(None, None, "totally-unknown-command"); - - assert!(resolved.is_empty()); -} diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index e5897d978..97df9335f 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -267,8 +267,6 @@ pub async fn restore_managed_agents_on_launch( .lock() .map_err(|error| error.to_string())?; - let mut successfully_spawned: Vec = Vec::new(); - for (pubkey, result) in spawn_results { let record = match find_managed_agent_mut(&mut records, &pubkey) { Ok(r) => r, @@ -284,7 +282,6 @@ pub async fn restore_managed_agents_on_launch( record.last_exit_code = None; record.last_error = None; runtimes.insert(pubkey.clone(), process); - successfully_spawned.push(pubkey); } Err(error) => { record.updated_at = util::now_iso(); @@ -293,58 +290,8 @@ pub async fn restore_managed_agents_on_launch( } } - // Collect profile reconciliation data for successfully spawned agents before - // releasing the lock. This mirrors the fire-and-forget pattern in - // start_managed_agent — ensuring boot-restored agents get the same profile - // self-healing as UI-started agents. - let reconcile_personas = super::load_personas(app).unwrap_or_default(); - let reconcile_items: Vec<(String, crate::commands::ProfileReconcileData)> = - successfully_spawned - .iter() - .filter_map(|pubkey| { - let record = records.iter().find(|r| r.pubkey == *pubkey)?; - // Resolve the effective harness for the avatar-fallback - // derivation (the snapshot may be empty/stale for an inherited - // harness). Mirrors the UI start path. - let effective_command = crate::managed_agents::effective_agent_command( - record.persona_id.as_deref(), - &reconcile_personas, - record.agent_command_override.as_deref(), - ); - Some(( - pubkey.clone(), - crate::commands::ProfileReconcileData { - private_key_nsec: record.private_key_nsec.clone(), - name: record.name.clone(), - relay_url: record.relay_url.clone(), - avatar_url: record.avatar_url.clone(), - auth_tag: record.auth_tag.clone(), - pubkey: record.pubkey.clone(), - agent_command: effective_command, - persona_id: record.persona_id.clone(), - }, - )) - }) - .collect(); - save_managed_agents(app, &records)?; - // ── Profile reconciliation (fire-and-forget) ──────────────────────────── - // Spawn background tasks to ensure each restored agent's kind:0 profile is - // published on the relay. Same pattern as the UI start path. - for (pubkey, data) in reconcile_items { - let reconcile_app = app.clone(); - tauri::async_runtime::spawn(async move { - let state = reconcile_app.state::(); - if let Err(e) = - crate::commands::reconcile_agent_profile(&state, &reconcile_app, &pubkey, &data) - .await - { - eprintln!("buzz-desktop: profile reconciliation failed for agent {pubkey}: {e}"); - } - }); - } - Ok(()) } diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 425ea835d..03cfa8c90 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -428,58 +428,6 @@ pub async fn sync_managed_agent_profile( Ok(()) } -// ── Agent profile query ───────────────────────────────────────────────────── - -/// Query the relay for an agent's kind:0 profile event. -/// -/// Queries the relay identified by `relay_url`. Callers uniformly pass the -/// relay resolved by `effective_agent_relay_url` for every agent regardless of -/// backend — an explicit per-agent pin, or the active workspace relay when the -/// agent has none — so the query targets the host the profile is actually -/// published to. -/// -/// Returns the parsed profile content (display_name, picture) if a kind:0 event -/// exists for the given pubkey, or `None` if no profile is published. -pub async fn query_agent_profile( - state: &AppState, - relay_url: &str, - agent_pubkey: &str, -) -> Result, String> { - let filter = serde_json::json!({ - "authors": [agent_pubkey], - "kinds": [0], - "limit": 1 - }); - - let events = query_relay_at(state, &relay_http_base_url(relay_url), &[filter]).await?; - - let Some(event) = events.first() else { - return Ok(None); - }; - - let Ok(content) = serde_json::from_str::(&event.content) else { - return Ok(None); - }; - - Ok(Some(AgentProfileInfo { - display_name: content - .get("display_name") - .and_then(|v| v.as_str()) - .map(str::to_string), - picture: content - .get("picture") - .and_then(|v| v.as_str()) - .map(str::to_string), - })) -} - -/// Parsed fields from a kind:0 profile event. -#[derive(Debug, Clone)] -pub struct AgentProfileInfo { - pub display_name: Option, - pub picture: Option, -} - // ── Signed-event submission ───────────────────────────────────────────────── /// Response from `POST /events`.