mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Tyler Longwell
parent
433b1794d5
commit
b3b669f40f
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user