Desktop: stop auto-healing agent kind:0 on start and restore

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 <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
2026-06-28 16:48:07 -04:00
co-authored by Tyler Longwell
parent 433b1794d5
commit b3b669f40f
4 changed files with 3 additions and 409 deletions
+3 -204
View File
@@ -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<String>,
pub(crate) auth_tag: Option<String>,
/// 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<String>,
}
#[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::<AppState>();
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<String>,
relay_picture: Option<String>,
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,
@@ -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());
}
@@ -267,8 +267,6 @@ pub async fn restore_managed_agents_on_launch(
.lock()
.map_err(|error| error.to_string())?;
let mut successfully_spawned: Vec<String> = 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::<AppState>();
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(())
}
-52
View File
@@ -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<Option<AgentProfileInfo>, 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::<serde_json::Value>(&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<String>,
pub picture: Option<String>,
}
// ── Signed-event submission ─────────────────────────────────────────────────
/// Response from `POST /events`.