Skip to content

Commit 73cd8d0

Browse files
wpfleger96npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw
andauthored
fix: skip avatar reconciliation for legacy agent records (#933)
Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw <4a1bfa0013bc6d14a8d600d8bf6392efefbd2a26ac3c96c9b2a106b0d12297ca@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw <4a1bfa0013bc6d14a8d600d8bf6392efefbd2a26ac3c96c9b2a106b0d12297ca@sprout-oss.stage.blox.sqprod.co>
1 parent 165b9f7 commit 73cd8d0

3 files changed

Lines changed: 175 additions & 52 deletions

File tree

desktop/scripts/check-file-sizes.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const rules = [
3030
// Do not add to this list; split the file instead. Remove each entry as its
3131
// file is broken up. Tracked as a follow-up.
3232
const overrides = new Map([
33-
["src-tauri/src/commands/agents.rs", 1208],
33+
["src-tauri/src/commands/agents.rs", 1287],
3434
["src-tauri/src/managed_agents/nest.rs", 1420],
3535
["src-tauri/src/managed_agents/runtime.rs", 1465],
3636
["src-tauri/src/managed_agents/persona_card.rs", 1050],

desktop/src-tauri/src/commands/agents.rs

Lines changed: 129 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -695,17 +695,25 @@ pub async fn create_managed_agent(
695695
}
696696

697697
/// Data needed for background profile reconciliation after agent start.
698-
struct ProfileReconcileData {
699-
private_key_nsec: String,
700-
name: String,
701-
relay_url: String,
702-
/// Expected avatar URL for the published profile. Resolved at start from the
703-
/// record's persisted `avatar_url` (the exact URL published at creation),
704-
/// falling back to persona/command derivation only for pre-existing records
705-
/// that have no stored value — so old records still self-heal without
706-
/// regressing a user-overridden avatar.
707-
avatar_url: Option<String>,
708-
auth_tag: Option<String>,
698+
pub(crate) struct ProfileReconcileData {
699+
pub(crate) private_key_nsec: String,
700+
pub(crate) name: String,
701+
pub(crate) relay_url: String,
702+
/// Expected avatar URL for the published profile. `None` for legacy records
703+
/// that predate the `avatar_url` field — these will be backfilled from the
704+
/// relay's existing kind:0 profile on first reconciliation.
705+
pub(crate) avatar_url: Option<String>,
706+
pub(crate) auth_tag: Option<String>,
707+
/// The agent's pubkey (hex). Needed to update the persisted record during
708+
/// avatar backfill migration.
709+
pub(crate) pubkey: String,
710+
/// The agent's command (e.g. "goose"). Used as fallback when no profile
711+
/// exists on the relay during avatar backfill.
712+
pub(crate) agent_command: String,
713+
/// Persona ID if this agent was created from a persona. Used during avatar
714+
/// backfill to recover the correct avatar from the persona record when the
715+
/// relay profile has been corrupted.
716+
pub(crate) persona_id: Option<String>,
709717
}
710718

711719
#[tauri::command]
@@ -745,14 +753,15 @@ pub async fn start_managed_agent(
745753

746754
let record = find_managed_agent_mut(&mut records, &pubkey)?;
747755

748-
let expected_avatar = reconcile_avatar(record.avatar_url.as_deref(), &record.agent_command);
749-
750756
let reconcile = ProfileReconcileData {
751757
private_key_nsec: record.private_key_nsec.clone(),
752758
name: record.name.clone(),
753759
relay_url: record.relay_url.clone(),
754-
avatar_url: expected_avatar,
760+
avatar_url: record.avatar_url.clone(),
755761
auth_tag: record.auth_tag.clone(),
762+
pubkey: record.pubkey.clone(),
763+
agent_command: record.agent_command.clone(),
764+
persona_id: record.persona_id.clone(),
756765
};
757766

758767
let target = if record.backend == BackendKind::Local {
@@ -812,15 +821,17 @@ pub async fn start_managed_agent(
812821
// ── Profile reconciliation (fire-and-forget) ────────────────────────────
813822
// On successful start, spawn a background task to ensure the agent's kind:0
814823
// profile is published on the relay. This self-heals cases where the initial
815-
// profile sync at creation time failed silently.
824+
// profile sync at creation time failed silently. For legacy records (pre-PR-921)
825+
// with no persisted avatar, this also backfills the avatar from the relay.
816826
if result.is_ok() {
817827
let reconcile_pubkey = pubkey.clone();
818828
let reconcile_app = app.clone();
819829
tauri::async_runtime::spawn(async move {
820830
use tauri::Manager;
821831
let state = reconcile_app.state::<AppState>();
822832
if let Err(e) =
823-
reconcile_agent_profile(&state, &reconcile_pubkey, &reconcile_data).await
833+
reconcile_agent_profile(&state, &reconcile_app, &reconcile_pubkey, &reconcile_data)
834+
.await
824835
{
825836
eprintln!(
826837
"sprout-desktop: profile reconciliation failed for agent {reconcile_pubkey}: {e}"
@@ -832,29 +843,95 @@ pub async fn start_managed_agent(
832843
result
833844
}
834845

846+
/// Resolve the avatar to backfill for a legacy agent record (pre-PR-921, no
847+
/// stored `avatar_url`).
848+
///
849+
/// Priority: the persona's avatar wins, because the old reconciliation code
850+
/// could have overwritten the relay's kind:0 `picture` with the command default
851+
/// — making the relay an unreliable source for persona-backed agents. Only fall
852+
/// back to the relay's `picture`, then the command icon, for agents with no
853+
/// persona avatar to recover from.
854+
fn resolve_legacy_avatar(
855+
persona_avatar: Option<String>,
856+
relay_picture: Option<String>,
857+
agent_command: &str,
858+
) -> String {
859+
persona_avatar
860+
.or(relay_picture)
861+
.or_else(|| managed_agent_avatar_url(agent_command))
862+
.unwrap_or_default()
863+
}
864+
835865
/// Reconcile an agent's kind:0 profile on the relay.
836866
///
837867
/// Queries the relay for the agent's existing profile and re-publishes if missing
838868
/// or stale (display_name or picture mismatch). This is fire-and-forget — errors
839869
/// are returned to the caller for logging but never block agent startup.
840870
///
871+
/// For legacy records (pre-PR-921) where `avatar_url` is `None`, this function
872+
/// backfills via `resolve_legacy_avatar` — preferring the persona record's avatar
873+
/// over the relay's `picture`, since the old code may have corrupted the relay
874+
/// profile — and persists the updated record. After backfill, normal
875+
/// reconciliation proceeds.
876+
///
841877
/// Query and publish both target the agent's stored `relay_url` so that, under
842878
/// an active workspace relay override, reconciliation reads and writes the same
843879
/// relay the agent's profile actually lives on.
844-
async fn reconcile_agent_profile(
880+
pub(crate) async fn reconcile_agent_profile(
845881
state: &AppState,
882+
app: &AppHandle,
846883
agent_pubkey: &str,
847884
data: &ProfileReconcileData,
848885
) -> Result<(), String> {
849886
use crate::relay::{query_agent_profile, sync_managed_agent_profile};
850887

851-
// Compare against the avatar persisted at creation time — never re-derive it.
852-
let expected_avatar = data.avatar_url.as_deref();
853-
854-
// Query the same relay the profile is published to (the stored relay_url).
888+
// Query the relay for the agent's existing kind:0 profile.
855889
let existing = query_agent_profile(state, &data.relay_url, agent_pubkey).await?;
856890

857-
if !profile_needs_sync(existing.as_ref(), &data.name, expected_avatar) {
891+
// Resolve the expected avatar — backfilling for legacy records that have no
892+
// stored avatar_url yet.
893+
let expected_avatar = match data.avatar_url.as_deref() {
894+
Some(url) => url.to_string(),
895+
None => {
896+
// Legacy record: the relay profile may have been corrupted by the
897+
// old reconciliation code (it overwrote the persona avatar with the
898+
// command default), so the persona record is the authoritative source.
899+
let persona_avatar = data.persona_id.as_ref().and_then(|pid| {
900+
load_personas(app)
901+
.ok()?
902+
.into_iter()
903+
.find(|p| p.id == *pid)?
904+
.avatar_url
905+
});
906+
907+
let backfilled = resolve_legacy_avatar(
908+
persona_avatar,
909+
existing.as_ref().and_then(|info| info.picture.clone()),
910+
&data.agent_command,
911+
);
912+
913+
// Persist the backfilled avatar so this migration only runs once.
914+
if !backfilled.is_empty() {
915+
let _store_guard = state
916+
.managed_agents_store_lock
917+
.lock()
918+
.map_err(|e| e.to_string())?;
919+
let mut records = load_managed_agents(app)?;
920+
if let Some(record) = records.iter_mut().find(|r| r.pubkey == data.pubkey) {
921+
record.avatar_url = Some(backfilled.clone());
922+
save_managed_agents(app, &records)?;
923+
}
924+
}
925+
926+
backfilled
927+
}
928+
};
929+
930+
if expected_avatar.is_empty() {
931+
return Ok(());
932+
}
933+
934+
if !profile_needs_sync(existing.as_ref(), &data.name, Some(&expected_avatar)) {
858935
return Ok(());
859936
}
860937

@@ -866,7 +943,7 @@ async fn reconcile_agent_profile(
866943
&data.relay_url,
867944
&agent_keys,
868945
&data.name,
869-
expected_avatar,
946+
Some(&expected_avatar),
870947
data.auth_tag.as_deref(),
871948
)
872949
.await
@@ -890,18 +967,6 @@ fn profile_needs_sync(
890967
}
891968
}
892969

893-
/// Resolve the avatar a managed agent's profile should reconcile against.
894-
/// Stored value (persisted at creation) wins; legacy records that predate the
895-
/// field (`stored == None`) fall back to the command-based derivation — the
896-
/// same source the create path used. Persona config is never consulted: doing
897-
/// so diverges from what was published and overwrites user intent on restart.
898-
fn reconcile_avatar(stored: Option<&str>, agent_command: &str) -> Option<String> {
899-
match stored {
900-
Some(url) => Some(url.to_string()),
901-
None => managed_agent_avatar_url(agent_command),
902-
}
903-
}
904-
905970
#[tauri::command]
906971
pub fn stop_managed_agent(
907972
pubkey: String,
@@ -1182,26 +1247,40 @@ mod tests {
11821247
assert!(profile_needs_sync(Some(&existing), "Duncan", None));
11831248
}
11841249

1185-
/// Legacy records (`avatar_url: None`) must reconcile against
1186-
/// `managed_agent_avatar_url(agent_command)` — never persona config —
1187-
/// matching what the original create path published.
11881250
#[test]
1189-
fn reconcile_avatar_legacy_record_uses_command_not_persona() {
1190-
let resolved = reconcile_avatar(None, "goose");
1191-
1192-
assert_eq!(resolved, managed_agent_avatar_url("goose"));
1193-
assert!(
1194-
resolved.is_some(),
1195-
"goose command should have a known avatar"
1251+
fn legacy_avatar_prefers_persona_over_corrupted_relay_picture() {
1252+
// The regression: the relay picture was overwritten with the command
1253+
// default. The persona avatar must win so the correct avatar is restored.
1254+
let resolved = resolve_legacy_avatar(
1255+
Some("https://x/persona.png".to_string()),
1256+
Some("https://x/default-icon.png".to_string()),
1257+
"goose",
11961258
);
1259+
1260+
assert_eq!(resolved, "https://x/persona.png");
1261+
}
1262+
1263+
#[test]
1264+
fn legacy_avatar_falls_back_to_relay_picture_without_persona() {
1265+
let resolved =
1266+
resolve_legacy_avatar(None, Some("https://x/relay.png".to_string()), "goose");
1267+
1268+
assert_eq!(resolved, "https://x/relay.png");
1269+
}
1270+
1271+
#[test]
1272+
fn legacy_avatar_falls_back_to_command_icon_when_no_persona_or_relay() {
1273+
use crate::managed_agents::managed_agent_avatar_url;
1274+
1275+
let resolved = resolve_legacy_avatar(None, None, "goose");
1276+
1277+
assert_eq!(resolved, managed_agent_avatar_url("goose").unwrap());
11971278
}
11981279

1199-
/// New records persist their avatar at creation; the stored value is used
1200-
/// verbatim, never falling back to command derivation.
12011280
#[test]
1202-
fn reconcile_avatar_stored_value_wins() {
1203-
let resolved = reconcile_avatar(Some("https://custom/avatar.png"), "goose");
1281+
fn legacy_avatar_empty_when_nothing_resolves() {
1282+
let resolved = resolve_legacy_avatar(None, None, "totally-unknown-command");
12041283

1205-
assert_eq!(resolved.as_deref(), Some("https://custom/avatar.png"));
1284+
assert!(resolved.is_empty());
12061285
}
12071286
}

desktop/src-tauri/src/managed_agents/restore.rs

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,8 @@ pub async fn restore_managed_agents_on_launch(
164164
.lock()
165165
.map_err(|error| error.to_string())?;
166166

167+
let mut successfully_spawned: Vec<String> = Vec::new();
168+
167169
for (pubkey, result) in spawn_results {
168170
let record = match find_managed_agent_mut(&mut records, &pubkey) {
169171
Ok(r) => r,
@@ -178,7 +180,8 @@ pub async fn restore_managed_agents_on_launch(
178180
record.last_stopped_at = None;
179181
record.last_exit_code = None;
180182
record.last_error = None;
181-
runtimes.insert(pubkey, ManagedAgentProcess { child, log_path });
183+
runtimes.insert(pubkey.clone(), ManagedAgentProcess { child, log_path });
184+
successfully_spawned.push(pubkey);
182185
}
183186
Err(error) => {
184187
record.updated_at = util::now_iso();
@@ -187,8 +190,49 @@ pub async fn restore_managed_agents_on_launch(
187190
}
188191
}
189192

193+
// Collect profile reconciliation data for successfully spawned agents before
194+
// releasing the lock. This mirrors the fire-and-forget pattern in
195+
// start_managed_agent — ensuring boot-restored agents get the same profile
196+
// self-healing as UI-started agents.
197+
let reconcile_items: Vec<(String, crate::commands::ProfileReconcileData)> =
198+
successfully_spawned
199+
.iter()
200+
.filter_map(|pubkey| {
201+
let record = records.iter().find(|r| r.pubkey == *pubkey)?;
202+
Some((
203+
pubkey.clone(),
204+
crate::commands::ProfileReconcileData {
205+
private_key_nsec: record.private_key_nsec.clone(),
206+
name: record.name.clone(),
207+
relay_url: record.relay_url.clone(),
208+
avatar_url: record.avatar_url.clone(),
209+
auth_tag: record.auth_tag.clone(),
210+
pubkey: record.pubkey.clone(),
211+
agent_command: record.agent_command.clone(),
212+
persona_id: record.persona_id.clone(),
213+
},
214+
))
215+
})
216+
.collect();
217+
190218
save_managed_agents(app, &records)?;
191219

220+
// ── Profile reconciliation (fire-and-forget) ────────────────────────────
221+
// Spawn background tasks to ensure each restored agent's kind:0 profile is
222+
// published on the relay. Same pattern as the UI start path.
223+
for (pubkey, data) in reconcile_items {
224+
let reconcile_app = app.clone();
225+
tauri::async_runtime::spawn(async move {
226+
let state = reconcile_app.state::<AppState>();
227+
if let Err(e) =
228+
crate::commands::reconcile_agent_profile(&state, &reconcile_app, &pubkey, &data)
229+
.await
230+
{
231+
eprintln!("sprout-desktop: profile reconciliation failed for agent {pubkey}: {e}");
232+
}
233+
});
234+
}
235+
192236
Ok(())
193237
}
194238

0 commit comments

Comments
 (0)