Skip to content

Commit f42839c

Browse files
authored
feat(desktop): worktree agent data sync, retired persona cleanup, mcp_command reconciliation (#728)
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
1 parent 3582157 commit f42839c

9 files changed

Lines changed: 839 additions & 18 deletions

File tree

desktop/scripts/check-file-sizes.mjs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const rules = [
3131
// Exceptions should stay rare and temporary. Prefer splitting files instead.
3232
const overrides = new Map([
3333
["src-tauri/src/managed_agents/nest.rs", 1420], // version-gated AGENTS.md + SKILL.md refresh + .agents/.claude symlink migration + ensure_skill_symlinks (all known providers) + managed section upsert + dynamic agent context + tests
34-
["src-tauri/src/managed_agents/personas.rs", 950], // built-in persona system prompts (Solo + Kit + Scout) + merge_personas inequality checks + persona pack import/uninstall/list + uninstall safety check
34+
["src-tauri/src/managed_agents/personas.rs", 980], // built-in persona system prompts (Solo + Kit + Scout) + merge_personas inequality checks + persona pack import/uninstall/list + uninstall safety check + retired persona migration (RETIRED_PERSONAS constant + migrate_retired_personas)
3535
["src-tauri/src/managed_agents/teams.rs", 580], // built-in team registry (Kit & Scout) + merge_teams + validate_team_deletion + JSON export/import + tests
3636
["src-tauri/src/managed_agents/persona_card.rs", 970], // PNG/ZIP/MD persona card codec + pack-zip detection + nested root finder + provider/model/namePool fields + 27 unit tests
3737
["src/app/AppShell.tsx", 835], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + projects view + memory-leak safeguards + home-badge state lifted here so it consumes the same NIP-RS read-state as the sidebar (single ReadStateManager) + dock bounce wiring + mark-all-read context + channel notification callback + desktopEnabled guard
@@ -46,6 +46,7 @@ const overrides = new Map([
4646
["src/features/settings/ui/SettingsView.tsx", 600],
4747
["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav
4848
["src/shared/api/relayClientSession.ts", 1040], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + ConnectionState plumbing & stall-watchdog wiring for half-open WS detection (Warp orange-icon case) + terminal session latch (auth rejection no longer racing back to reconnecting) — emitter + watchdog + reconnect policy logic extracted to relayConnectionStateEmitter.ts / relayStallWatchdog.ts / relayReconnectPolicy.ts
49+
["src-tauri/src/migration.rs", 630], // worktree shared-agent-data symlink sync (SHARED_AGENT_FILES symlink-to-canonical) + mcp_command provider reconciliation + tests
4950
["src-tauri/src/commands/media.rs", 730], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg via resolve_command, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests
5051
["src-tauri/src/commands/agents.rs", 881], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field + NIP-OA auth_tag in deploy payload
5152
["src-tauri/src/commands/messages.rs", 515], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ + edit_message media_tags param (Slack-style attachment-editable edits)
@@ -77,7 +78,7 @@ const overrides = new Map([
7778
["src-tauri/src/huddle/tts.rs", 1380], // TTS pipeline + session warmup + cancel/shutdown handling + apply_fade_out (fade-out only — leading fade removed 2026-05-18 after onset-attenuation regression measured in examples/pocket_onset_probe.rs) + FIRST_APPEND_LEAD_IN_SAMPLES + build_sentence_append_plan (pure helper enforcing the lead-in fires exactly once per utterance, not per sentence — see lead_in_pad_fires_exactly_once_per_utterance regression test) + normalize_for_playback (per-sentence peak normalization to -3 dBFS ceiling with MAX_GAIN cap) + 30 unit tests (18 interrupt + 5 fade-out + 1 first-append-lead-in + 3 build-sentence-append-plan + 6 normalize)
7879
["src-tauri/src/relay.rs", 510], // +4 lines for NIP-OA auth tag injection in profile sync (build_profile_event) + verification test
7980
["src-tauri/src/commands/pairing.rs", 600], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + NIP-43 probe + event parsing helpers
80-
["src-tauri/src/lib.rs", 730], // +4 lines for PairingHandle managed state + 3 pairing command registrations + parse_message_deep_link helper extracted with 6 unit tests covering empty-param filter regression
81+
["src-tauri/src/lib.rs", 733], // +4 lines for PairingHandle managed state + 3 pairing command registrations + parse_message_deep_link helper extracted with 6 unit tests covering empty-param filter regression + mod migration + sync_shared_agent_data/reconcile_provider_mcp_commands calls on launch
8182
["src/shared/api/tauri.ts", 1212], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role) + prevent sleep + AcpProviderCatalogEntry raw types + fromRawAcpProviderCatalogEntry converter + installAcpRuntime
8283
]);
8384

desktop/src-tauri/src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ mod events;
44
mod huddle;
55
mod managed_agents;
66
mod media_proxy;
7+
mod migration;
78
mod models;
89
pub mod nostr_convert;
910
mod prevent_sleep;
@@ -398,6 +399,12 @@ pub fn run() {
398399
let app_handle = app.handle().clone();
399400
let shutdown_started = Arc::clone(&restore_shutdown_started);
400401

402+
// Sync shared agent data from the canonical dev data directory to
403+
// this worktree's data directory. Must run before
404+
// restore_managed_agents_on_launch (which reads managed-agents.json).
405+
migration::sync_shared_agent_data(&app_handle);
406+
migration::reconcile_provider_mcp_commands(&app_handle);
407+
401408
// Resolve persisted identity key (env var → file → generate+save).
402409
// This is fatal — the app should not start with an ephemeral identity
403410
// that will be lost on restart, as that silently breaks channel

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

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,33 @@ Your name is Scout. You are friendly and helpful. You are understated, but have
461461
},
462462
];
463463

464+
const RETIRED_PERSONAS: &[(&str, &str)] = &[
465+
(
466+
"builtin:orchestrator",
467+
"You are an orchestration agent. Coordinate multi-step work across specialized agents, keep the overall plan moving, and synthesize results into a clear final outcome. When another agent should take a task, @mention them explicitly with the assignment, expected deliverable, and any relevant constraints or deadlines.",
468+
),
469+
(
470+
"builtin:researcher",
471+
"You are a research agent. Gather relevant information, compare sources, call out uncertainty, and return concise findings with evidence.",
472+
),
473+
(
474+
"builtin:planner",
475+
"You are a planning agent. Turn ambiguous requests into structured plans with milestones, dependencies, risks, and clear next actions. Do not implement the work yourself unless asked.",
476+
),
477+
(
478+
"builtin:implementer",
479+
"You are a builder agent. Execute tasks directly, make code and configuration changes carefully, validate the result, and explain important decisions and follow-up items.",
480+
),
481+
(
482+
"builtin:refactor",
483+
"You are a refactoring agent. Improve structure, naming, duplication, and module boundaries without changing externally observable behavior. Keep changes incremental, preserve compatibility, and add or update validation when behavior could drift.",
484+
),
485+
(
486+
"builtin:reviewer",
487+
"You are a review agent. Inspect plans, code, and outputs for bugs, regressions, edge cases, security issues, and missing tests. Prioritize findings by severity, cite concrete evidence, and keep summaries secondary to the actual review.",
488+
),
489+
];
490+
464491
fn personas_store_path(app: &AppHandle) -> Result<PathBuf, String> {
465492
Ok(managed_agents_base_dir(app)?.join("personas.json"))
466493
}
@@ -562,10 +589,55 @@ fn merge_personas(mut stored: Vec<PersonaRecord>, now: &str) -> (Vec<PersonaReco
562589
}
563590
}
564591

592+
// Soft-deprecate retired built-in personas that were replaced by
593+
// Solo/Kit/Scout. Runs after demotion so the records are already
594+
// marked as non-builtin.
595+
if migrate_retired_personas(&mut stored, now) {
596+
changed = true;
597+
}
598+
565599
sort_personas(&mut stored);
566600
(stored, changed)
567601
}
568602

603+
/// Soft-deprecate retired built-in personas by appending " (retired)" to
604+
/// their display name and marking them inactive. Never removes records —
605+
/// the cost is 6 extra records for pre-transition users, but this
606+
/// eliminates dangling `persona_id` references in managed-agents.json
607+
/// and teams.json.
608+
fn migrate_retired_personas(stored: &mut [PersonaRecord], now: &str) -> bool {
609+
let mut changed = false;
610+
611+
for record in stored.iter_mut() {
612+
if let Some((_, original_prompt)) = RETIRED_PERSONAS.iter().find(|(id, _)| *id == record.id)
613+
{
614+
let retired_suffix = " (retired)";
615+
let needs_suffix = !record.display_name.ends_with(retired_suffix);
616+
if needs_suffix || record.is_active {
617+
let was_unmodified = record.system_prompt == *original_prompt;
618+
eprintln!(
619+
"sprout-desktop: persona-migration: retiring {} persona '{}' → '{} (retired)'",
620+
if was_unmodified {
621+
"unmodified"
622+
} else {
623+
"customized"
624+
},
625+
record.display_name,
626+
record.display_name,
627+
);
628+
if needs_suffix {
629+
record.display_name = format!("{}{}", record.display_name, retired_suffix);
630+
}
631+
record.is_active = false;
632+
record.updated_at = now.to_string();
633+
changed = true;
634+
}
635+
}
636+
}
637+
638+
changed
639+
}
640+
569641
pub fn ensure_persona_is_active(
570642
personas: &[PersonaRecord],
571643
persona_id: &str,
@@ -893,7 +965,7 @@ pub fn save_personas(app: &AppHandle, records: &[PersonaRecord]) -> Result<(), S
893965
let path = personas_store_path(app)?;
894966
let payload = serde_json::to_vec_pretty(&sorted)
895967
.map_err(|error| format!("failed to serialize persona store: {error}"))?;
896-
fs::write(&path, payload).map_err(|error| format!("failed to write persona store: {error}"))
968+
crate::managed_agents::storage::atomic_write_json(&path, &payload)
897969
}
898970

899971
#[cfg(test)]

desktop/src-tauri/src/managed_agents/personas/tests.rs

Lines changed: 118 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use super::{
2-
ensure_persona_ids_are_active, ensure_persona_is_active, merge_personas, validate_pack_id,
3-
validate_persona_activation_change, validate_persona_deletion, BUILT_IN_PERSONAS,
2+
ensure_persona_ids_are_active, ensure_persona_is_active, merge_personas,
3+
migrate_retired_personas, validate_pack_id, validate_persona_activation_change,
4+
validate_persona_deletion, BUILT_IN_PERSONAS, RETIRED_PERSONAS,
45
};
56
use crate::managed_agents::PersonaRecord;
67

@@ -161,6 +162,9 @@ fn merge_personas_backfills_new_builtins_for_existing_store() {
161162

162163
#[test]
163164
fn merge_personas_demotes_retired_builtins() {
165+
// custom_persona uses "Custom prompt", which doesn't match the original
166+
// retired system prompt, so the migration pass soft-deprecates rather
167+
// than removes the record.
164168
let mut retired = custom_persona("builtin:reviewer", "Reviewer");
165169
retired.is_builtin = true;
166170
retired.is_active = true;
@@ -172,9 +176,11 @@ fn merge_personas_demotes_retired_builtins() {
172176
let demoted = records
173177
.iter()
174178
.find(|record| record.id == "builtin:reviewer")
175-
.expect("retired built-in should be retained as a custom persona");
179+
.expect("retired built-in should be retained as a soft-deprecated custom persona");
176180
assert!(!demoted.is_builtin);
177-
assert!(demoted.is_active);
181+
// migrate_retired_personas deactivates customized retired personas.
182+
assert!(!demoted.is_active);
183+
assert_eq!(demoted.display_name, "Reviewer (retired)");
178184
assert_eq!(demoted.created_at, original_created_at);
179185
assert_eq!(demoted.updated_at, "2026-04-01T00:00:00Z");
180186
}
@@ -344,3 +350,111 @@ fn pack_id_rejects_too_long() {
344350
let max_id = "a".repeat(128);
345351
assert!(validate_pack_id(&max_id).is_ok());
346352
}
353+
354+
// ── migrate_retired_personas ──────────────────────────────────────────────────
355+
356+
#[test]
357+
fn migrate_retires_unmodified_personas() {
358+
let now = "2026-04-01T00:00:00Z";
359+
// Simulate a store from before the Solo/Kit/Scout transition: all 6
360+
// retired personas with original system prompts.
361+
let mut stored: Vec<PersonaRecord> = RETIRED_PERSONAS
362+
.iter()
363+
.map(|(id, prompt)| PersonaRecord {
364+
id: id.to_string(),
365+
system_prompt: prompt.to_string(),
366+
is_builtin: false, // already demoted by merge_personas
367+
..custom_persona(id, "Test Persona")
368+
})
369+
.collect();
370+
371+
let changed = migrate_retired_personas(&mut stored, now);
372+
373+
assert!(changed);
374+
assert_eq!(
375+
stored.len(),
376+
RETIRED_PERSONAS.len(),
377+
"all retired personas should be soft-deprecated, not removed",
378+
);
379+
assert!(
380+
stored
381+
.iter()
382+
.all(|r| r.display_name.ends_with(" (retired)")),
383+
"all retired personas should have ' (retired)' suffix",
384+
);
385+
assert!(
386+
stored.iter().all(|r| !r.is_active),
387+
"all retired personas should be inactive",
388+
);
389+
assert!(
390+
stored.iter().all(|r| r.updated_at == now),
391+
"all retired personas should have refreshed updated_at",
392+
);
393+
}
394+
395+
#[test]
396+
fn migrate_preserves_customized_personas() {
397+
let now = "2026-04-01T00:00:00Z";
398+
let mut stored = vec![PersonaRecord {
399+
id: "builtin:researcher".to_string(),
400+
display_name: "My Researcher".to_string(),
401+
system_prompt: "My custom research workflow with special instructions".to_string(),
402+
is_builtin: false,
403+
is_active: true,
404+
..custom_persona("builtin:researcher", "My Researcher")
405+
}];
406+
407+
let changed = migrate_retired_personas(&mut stored, now);
408+
409+
assert!(changed);
410+
assert_eq!(stored.len(), 1);
411+
let record = &stored[0];
412+
assert_eq!(record.display_name, "My Researcher (retired)");
413+
assert!(!record.is_active);
414+
assert_eq!(
415+
record.system_prompt,
416+
"My custom research workflow with special instructions"
417+
);
418+
assert_eq!(record.updated_at, now);
419+
}
420+
421+
#[test]
422+
fn migrate_is_idempotent() {
423+
let now = "2026-04-01T00:00:00Z";
424+
425+
// 1. Non-retired persona — no-op.
426+
let mut stored = vec![custom_persona("custom:test", "Custom")];
427+
assert!(!migrate_retired_personas(&mut stored, now));
428+
assert_eq!(stored.len(), 1);
429+
430+
// 2. Already-retired persona (display_name ends with " (retired)") — no-op.
431+
let mut stored_with_retired = vec![PersonaRecord {
432+
id: "builtin:researcher".to_string(),
433+
display_name: "Researcher (retired)".to_string(),
434+
system_prompt: "My custom prompt".to_string(),
435+
is_builtin: false,
436+
is_active: false,
437+
..custom_persona("builtin:researcher", "Researcher (retired)")
438+
}];
439+
assert!(
440+
!migrate_retired_personas(&mut stored_with_retired, now),
441+
"already-retired persona should not trigger another change"
442+
);
443+
444+
// 3. Retired persona still marked is_builtin: true (pre-demotion).
445+
// migrate_retired_personas should still soft-deprecate it.
446+
let mut stored_pre_demotion = vec![PersonaRecord {
447+
id: "builtin:reviewer".to_string(),
448+
display_name: "Reviewer".to_string(),
449+
system_prompt: "Custom review prompt".to_string(),
450+
is_builtin: true,
451+
is_active: true,
452+
..custom_persona("builtin:reviewer", "Reviewer")
453+
}];
454+
assert!(migrate_retired_personas(&mut stored_pre_demotion, now));
455+
assert_eq!(stored_pre_demotion[0].display_name, "Reviewer (retired)");
456+
assert!(!stored_pre_demotion[0].is_active);
457+
458+
// 4. Run again on result of (3) — should be no-op.
459+
assert!(!migrate_retired_personas(&mut stored_pre_demotion, now));
460+
}

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

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,18 @@ pub fn save_managed_agents(app: &AppHandle, records: &[ManagedAgentRecord]) -> R
5656
let payload = serde_json::to_vec_pretty(&sorted)
5757
.map_err(|error| format!("failed to serialize agent store: {error}"))?;
5858

59-
// Atomic write: write to a temp file then rename. This prevents partial
60-
// writes from corrupting the store if the process crashes mid-write.
61-
// rename() is atomic on the same filesystem on both macOS and Linux.
62-
let tmp_path = path.with_extension("json.tmp");
63-
fs::write(&tmp_path, &payload)
64-
.map_err(|error| format!("failed to write temp agent store: {error}"))?;
65-
fs::rename(&tmp_path, &path)
66-
.map_err(|error| format!("failed to rename temp agent store: {error}"))
59+
atomic_write_json(&path, &payload)
60+
}
61+
62+
/// Atomic, symlink-preserving JSON write.
63+
/// Resolves symlinks so the tmp+rename happens at the real target path,
64+
/// preserving any symlink at `path`.
65+
pub(crate) fn atomic_write_json(path: &Path, payload: &[u8]) -> Result<(), String> {
66+
let resolved = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
67+
let tmp = resolved.with_extension("json.tmp");
68+
std::fs::write(&tmp, payload).map_err(|e| format!("failed to write {}: {e}", tmp.display()))?;
69+
std::fs::rename(&tmp, &resolved)
70+
.map_err(|e| format!("failed to rename {}: {e}", resolved.display()))
6771
}
6872

6973
/// Maximum log file size before rotation (10 MB).

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ pub fn save_teams(app: &AppHandle, records: &[TeamRecord]) -> Result<(), String>
153153
let path = teams_store_path(app)?;
154154
let payload = serde_json::to_vec_pretty(&sorted)
155155
.map_err(|error| format!("failed to serialize teams store: {error}"))?;
156-
fs::write(&path, payload).map_err(|error| format!("failed to write teams store: {error}"))
156+
crate::managed_agents::storage::atomic_write_json(&path, &payload)
157157
}
158158

159159
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)