Skip to content

Commit 762a459

Browse files
wpfleger96npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
andauthored
fix: reconcile agent profile on startup when relay publish was missed (#921)
Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
1 parent 5d927ab commit 762a459

8 files changed

Lines changed: 309 additions & 16 deletions

File tree

desktop/scripts/check-file-sizes.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +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", 1190],
3334
["src-tauri/src/managed_agents/nest.rs", 1417],
3435
["src-tauri/src/managed_agents/runtime.rs", 1387],
3536
["src-tauri/src/huddle/tts.rs", 1364],

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,10 @@ pub async fn update_managed_agent(
239239
.map_err(|e| format!("failed to parse agent keys: {e}"))?;
240240
let relay_url = record.relay_url.clone();
241241
let display_name = record.name.clone();
242-
let avatar_url = managed_agent_avatar_url(&record.agent_command);
242+
let avatar_url = record
243+
.avatar_url
244+
.clone()
245+
.or_else(|| managed_agent_avatar_url(&record.agent_command));
243246
let auth_tag = record.auth_tag.clone();
244247
Some((agent_keys, relay_url, display_name, avatar_url, auth_tag))
245248
} else {

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

Lines changed: 231 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,7 @@ pub async fn create_managed_agent(
377377
};
378378

379379
// ── Phase 3: save record (sync lock) ───────────────────────────────────────
380-
let agent = {
380+
let (agent, resolved_avatar_url) = {
381381
let _store_guard = state
382382
.managed_agents_store_lock
383383
.lock()
@@ -453,13 +453,26 @@ pub async fn create_managed_agent(
453453
Some((pack_path, slug.to_owned()))
454454
});
455455

456+
// Resolve the avatar URL once at creation and persist it on the record.
457+
// This is the same logic the original publish used (user input, else
458+
// command-based fallback) — storing it lets reconciliation compare
459+
// against what was actually published instead of re-deriving it.
460+
let resolved_avatar_url = input
461+
.avatar_url
462+
.as_deref()
463+
.map(str::trim)
464+
.filter(|value| !value.is_empty())
465+
.map(str::to_string)
466+
.or_else(|| managed_agent_avatar_url(&agent_command));
467+
456468
let record = crate::managed_agents::ManagedAgentRecord {
457469
pubkey: pubkey.clone(),
458470
name: name.clone(),
459471
persona_id: requested_persona_id.clone(),
460472
private_key_nsec: private_key_nsec.clone(),
461473
auth_tag: auth_tag.clone(),
462474
relay_url: resolved_relay_url.clone(),
475+
avatar_url: resolved_avatar_url.clone(),
463476
acp_command: input
464477
.acp_command
465478
.as_deref()
@@ -534,7 +547,10 @@ pub async fn create_managed_agent(
534547
.iter()
535548
.find(|record| record.pubkey == pubkey)
536549
.ok_or_else(|| "created agent disappeared unexpectedly".to_string())?;
537-
build_managed_agent_summary(&app, record, &runtimes)?
550+
(
551+
build_managed_agent_summary(&app, record, &runtimes)?,
552+
resolved_avatar_url,
553+
)
538554
};
539555

540556
// ── Phase 3b: local spawn (async preflight outside store lock) ───────────
@@ -571,19 +587,14 @@ pub async fn create_managed_agent(
571587
try_regenerate_nest(&app);
572588

573589
// ── Phase 4: sync agent profile on relay (async, outside lock) ───────────
574-
let avatar_url = input
575-
.avatar_url
576-
.as_deref()
577-
.map(str::trim)
578-
.filter(|value| !value.is_empty())
579-
.map(str::to_string)
580-
.or_else(|| managed_agent_avatar_url(agent.agent_command.as_str()));
590+
// Use the avatar persisted on the record so the published profile and any
591+
// later reconciliation agree on the same value.
581592
let profile_sync_error = (sync_managed_agent_profile(
582593
&state,
583594
&resolved_relay_url,
584595
&agent_keys,
585596
&name,
586-
avatar_url.as_deref(),
597+
resolved_avatar_url.as_deref(),
587598
auth_tag.as_deref(),
588599
)
589600
.await)
@@ -665,6 +676,20 @@ pub async fn create_managed_agent(
665676
})
666677
}
667678

679+
/// Data needed for background profile reconciliation after agent start.
680+
struct ProfileReconcileData {
681+
private_key_nsec: String,
682+
name: String,
683+
relay_url: String,
684+
/// Expected avatar URL for the published profile. Resolved at start from the
685+
/// record's persisted `avatar_url` (the exact URL published at creation),
686+
/// falling back to persona/command derivation only for pre-existing records
687+
/// that have no stored value — so old records still self-heal without
688+
/// regressing a user-overridden avatar.
689+
avatar_url: Option<String>,
690+
auth_tag: Option<String>,
691+
}
692+
668693
#[tauri::command]
669694
pub async fn start_managed_agent(
670695
pubkey: String,
@@ -684,7 +709,8 @@ pub async fn start_managed_agent(
684709
}
685710

686711
// Collect backend info under lock; async preflight/spawn happens below.
687-
let target = {
712+
// Also snapshot profile reconciliation data for the background task.
713+
let (target, reconcile_data) = {
688714
let _store_guard = state
689715
.managed_agents_store_lock
690716
.lock()
@@ -701,18 +727,30 @@ pub async fn start_managed_agent(
701727

702728
let record = find_managed_agent_mut(&mut records, &pubkey)?;
703729

704-
if record.backend == BackendKind::Local {
730+
let expected_avatar = reconcile_avatar(record.avatar_url.as_deref(), &record.agent_command);
731+
732+
let reconcile = ProfileReconcileData {
733+
private_key_nsec: record.private_key_nsec.clone(),
734+
name: record.name.clone(),
735+
relay_url: record.relay_url.clone(),
736+
avatar_url: expected_avatar,
737+
auth_tag: record.auth_tag.clone(),
738+
};
739+
740+
let target = if record.backend == BackendKind::Local {
705741
StartTarget::Local
706742
} else {
707743
StartTarget::Provider {
708744
backend: record.backend.clone(),
709745
cached_binary_path: record.provider_binary_path.clone(),
710746
agent_json: build_deploy_payload(&app, record)?,
711747
}
712-
}
748+
};
749+
750+
(target, reconcile)
713751
};
714752

715-
match target {
753+
let result = match target {
716754
StartTarget::Local => {
717755
start_local_agent_with_preflight(&app, &state, &pubkey, &owner_hex, false).await
718756
}
@@ -751,6 +789,98 @@ pub async fn start_managed_agent(
751789
StartTarget::Provider { backend, .. } => Err(format!(
752790
"agent {pubkey} has unsupported backend kind: {backend:?}"
753791
)),
792+
};
793+
794+
// ── Profile reconciliation (fire-and-forget) ────────────────────────────
795+
// On successful start, spawn a background task to ensure the agent's kind:0
796+
// profile is published on the relay. This self-heals cases where the initial
797+
// profile sync at creation time failed silently.
798+
if result.is_ok() {
799+
let reconcile_pubkey = pubkey.clone();
800+
let reconcile_app = app.clone();
801+
tauri::async_runtime::spawn(async move {
802+
use tauri::Manager;
803+
let state = reconcile_app.state::<AppState>();
804+
if let Err(e) =
805+
reconcile_agent_profile(&state, &reconcile_pubkey, &reconcile_data).await
806+
{
807+
eprintln!(
808+
"sprout-desktop: profile reconciliation failed for agent {reconcile_pubkey}: {e}"
809+
);
810+
}
811+
});
812+
}
813+
814+
result
815+
}
816+
817+
/// Reconcile an agent's kind:0 profile on the relay.
818+
///
819+
/// Queries the relay for the agent's existing profile and re-publishes if missing
820+
/// or stale (display_name or picture mismatch). This is fire-and-forget — errors
821+
/// are returned to the caller for logging but never block agent startup.
822+
///
823+
/// Query and publish both target the agent's stored `relay_url` so that, under
824+
/// an active workspace relay override, reconciliation reads and writes the same
825+
/// relay the agent's profile actually lives on.
826+
async fn reconcile_agent_profile(
827+
state: &AppState,
828+
agent_pubkey: &str,
829+
data: &ProfileReconcileData,
830+
) -> Result<(), String> {
831+
use crate::relay::{query_agent_profile, sync_managed_agent_profile};
832+
833+
// Compare against the avatar persisted at creation time — never re-derive it.
834+
let expected_avatar = data.avatar_url.as_deref();
835+
836+
// Query the same relay the profile is published to (the stored relay_url).
837+
let existing = query_agent_profile(state, &data.relay_url, agent_pubkey).await?;
838+
839+
if !profile_needs_sync(existing.as_ref(), &data.name, expected_avatar) {
840+
return Ok(());
841+
}
842+
843+
let agent_keys = Keys::parse(&data.private_key_nsec)
844+
.map_err(|e| format!("failed to parse agent keys: {e}"))?;
845+
846+
sync_managed_agent_profile(
847+
state,
848+
&data.relay_url,
849+
&agent_keys,
850+
&data.name,
851+
expected_avatar,
852+
data.auth_tag.as_deref(),
853+
)
854+
.await
855+
}
856+
857+
/// Decide whether a published profile is missing or stale relative to the
858+
/// expected name and avatar. A missing profile always needs sync; a present
859+
/// one is stale when either the display name or picture diverges.
860+
fn profile_needs_sync(
861+
existing: Option<&crate::relay::AgentProfileInfo>,
862+
expected_name: &str,
863+
expected_avatar: Option<&str>,
864+
) -> bool {
865+
match existing {
866+
None => true,
867+
Some(info) => {
868+
let name_matches = info.display_name.as_deref() == Some(expected_name);
869+
let picture_matches = info.picture.as_deref() == expected_avatar;
870+
!name_matches || !picture_matches
871+
}
872+
}
873+
}
874+
875+
/// Resolve the avatar a managed agent's profile should reconcile against.
876+
/// Stored value (persisted at creation) wins; legacy records that predate the
877+
/// field (`stored == None`) fall back to the command-based derivation — the
878+
/// same source the create path used. Persona config is never consulted: doing
879+
/// so diverges from what was published and overwrites user intent on restart.
880+
fn reconcile_avatar(stored: Option<&str>, agent_command: &str) -> Option<String> {
881+
match stored {
882+
Some(url) => Some(url.to_string()),
883+
None => managed_agent_avatar_url(agent_command),
754884
}
755885
}
756886

@@ -969,4 +1099,91 @@ mod tests {
9691099
})
9701100
);
9711101
}
1102+
1103+
fn profile(name: Option<&str>, picture: Option<&str>) -> crate::relay::AgentProfileInfo {
1104+
crate::relay::AgentProfileInfo {
1105+
display_name: name.map(str::to_string),
1106+
picture: picture.map(str::to_string),
1107+
}
1108+
}
1109+
1110+
#[test]
1111+
fn profile_needs_sync_when_missing() {
1112+
assert!(profile_needs_sync(None, "Duncan", Some("https://x/a.png")));
1113+
}
1114+
1115+
#[test]
1116+
fn profile_needs_sync_when_name_diverges() {
1117+
let existing = profile(Some("Stilgar"), Some("https://x/a.png"));
1118+
assert!(profile_needs_sync(
1119+
Some(&existing),
1120+
"Duncan",
1121+
Some("https://x/a.png")
1122+
));
1123+
}
1124+
1125+
#[test]
1126+
fn profile_needs_sync_when_picture_diverges() {
1127+
let existing = profile(Some("Duncan"), Some("https://x/old.png"));
1128+
assert!(profile_needs_sync(
1129+
Some(&existing),
1130+
"Duncan",
1131+
Some("https://x/new.png")
1132+
));
1133+
}
1134+
1135+
#[test]
1136+
fn profile_in_sync_when_name_and_picture_match() {
1137+
let existing = profile(Some("Duncan"), Some("https://x/a.png"));
1138+
assert!(!profile_needs_sync(
1139+
Some(&existing),
1140+
"Duncan",
1141+
Some("https://x/a.png")
1142+
));
1143+
}
1144+
1145+
#[test]
1146+
fn profile_in_sync_when_both_avatars_absent() {
1147+
let existing = profile(Some("Duncan"), None);
1148+
assert!(!profile_needs_sync(Some(&existing), "Duncan", None));
1149+
}
1150+
1151+
#[test]
1152+
fn profile_needs_sync_when_existing_name_is_none() {
1153+
let existing = profile(None, Some("https://x/a.png"));
1154+
assert!(profile_needs_sync(
1155+
Some(&existing),
1156+
"Duncan",
1157+
Some("https://x/a.png"),
1158+
));
1159+
}
1160+
1161+
#[test]
1162+
fn profile_needs_sync_when_expected_avatar_absent_but_published() {
1163+
let existing = profile(Some("Duncan"), Some("https://x/a.png"));
1164+
assert!(profile_needs_sync(Some(&existing), "Duncan", None));
1165+
}
1166+
1167+
/// Legacy records (`avatar_url: None`) must reconcile against
1168+
/// `managed_agent_avatar_url(agent_command)` — never persona config —
1169+
/// matching what the original create path published.
1170+
#[test]
1171+
fn reconcile_avatar_legacy_record_uses_command_not_persona() {
1172+
let resolved = reconcile_avatar(None, "goose");
1173+
1174+
assert_eq!(resolved, managed_agent_avatar_url("goose"));
1175+
assert!(
1176+
resolved.is_some(),
1177+
"goose command should have a known avatar"
1178+
);
1179+
}
1180+
1181+
/// New records persist their avatar at creation; the stored value is used
1182+
/// verbatim, never falling back to command derivation.
1183+
#[test]
1184+
fn reconcile_avatar_stored_value_wins() {
1185+
let resolved = reconcile_avatar(Some("https://custom/avatar.png"), "goose");
1186+
1187+
assert_eq!(resolved.as_deref(), Some("https://custom/avatar.png"));
1188+
}
9721189
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -962,6 +962,7 @@ mod tests {
962962
private_key_nsec: String::new(),
963963
auth_tag: None,
964964
relay_url: String::new(),
965+
avatar_url: None,
965966
acp_command: String::new(),
966967
agent_command: String::new(),
967968
agent_args: vec![],

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ mod tests {
6868
private_key_nsec: "nsec1fake".into(),
6969
auth_tag: Some("tag".into()),
7070
relay_url: "ws://localhost:3000".into(),
71+
avatar_url: None,
7172
acp_command: "sprout-acp".into(),
7273
agent_command: "goose".into(),
7374
agent_args: vec![],

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1255,6 +1255,7 @@ mod tests {
12551255
private_key_nsec: "nsec1fake".into(),
12561256
auth_tag,
12571257
relay_url: "ws://localhost:3000".into(),
1258+
avatar_url: None,
12581259
acp_command: "sprout-acp".into(),
12591260
agent_command: "goose".into(),
12601261
agent_args: vec![],

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@ pub struct ManagedAgentRecord {
8282
#[serde(default)]
8383
pub auth_tag: Option<String>,
8484
pub relay_url: String,
85+
/// Avatar URL resolved at creation time (user-supplied input, else the
86+
/// command-based fallback). Persisted so startup reconciliation compares
87+
/// against what was actually published rather than re-deriving it from
88+
/// persona config — which would silently overwrite user intent on restart.
89+
/// `#[serde(default)]` so pre-existing records deserialize as `None`.
90+
#[serde(default)]
91+
pub avatar_url: Option<String>,
8592
pub acp_command: String,
8693
pub agent_command: String,
8794
pub agent_args: Vec<String>,
@@ -614,6 +621,7 @@ mod tests {
614621
.expect("legacy agent record without auth_tag should deserialize");
615622

616623
assert_eq!(record.auth_tag, None);
624+
assert_eq!(record.avatar_url, None);
617625
assert_eq!(record.pubkey, "abcd1234");
618626
}
619627

0 commit comments

Comments
 (0)