From cf1f84cb16f078dbd30d089acab72b9b881efbd1 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 22 May 2026 17:00:18 -0400 Subject: [PATCH 1/5] feat(desktop): worktree agent data sync + retired persona cleanup Worktree dev builds (SPROUT_SHARE_IDENTITY=1) had empty agent panels because each worktree gets its own data directory that was never seeded. Copy-with-overwrite on every launch syncs the 3 agent JSON files from the canonical dev data dir, so worktrees see the same agents/personas/ teams as the main instance. Also cleans up 6 retired personas (Orchestrator, Researcher, Planner, Builder, Refactor, Reviewer) that persist from before the Solo/Kit/Scout transition -- unmodified ones are removed, user-customized ones are soft-deprecated as " (retired)" with is_active=false. --- desktop/src-tauri/src/lib.rs | 1 + .../src-tauri/src/managed_agents/personas.rs | 85 +++++++ .../src/managed_agents/personas/tests.rs | 94 +++++++- desktop/src-tauri/src/migration.rs | 228 ++++++++++++++++++ 4 files changed, 404 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index ffc3efafb..cc0ee568b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -356,6 +356,7 @@ pub fn run() { // resolving identity, so the persisted key is available at the new // path on first launch after the identifier change. migration::migrate_legacy_data_dir(&app_handle); + migration::sync_shared_agent_data(&app_handle); // Resolve persisted identity key (env var → file → generate+save). // This is fatal — the app should not start with an ephemeral identity diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index b3cc61ae6..d50734dfb 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -455,6 +455,39 @@ Your name is Scout. You are friendly and helpful. You are understated, but have }, ]; +const RETIRED_PERSONAS: &[(&str, &str, &str)] = &[ + ( + "builtin:orchestrator", + "Orchestrator", + "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.", + ), + ( + "builtin:researcher", + "Researcher", + "You are a research agent. Gather relevant information, compare sources, call out uncertainty, and return concise findings with evidence.", + ), + ( + "builtin:planner", + "Planner", + "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.", + ), + ( + "builtin:implementer", + "Builder", + "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.", + ), + ( + "builtin:refactor", + "Refactor", + "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.", + ), + ( + "builtin:reviewer", + "Reviewer", + "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.", + ), +]; + fn personas_store_path(app: &AppHandle) -> Result { Ok(managed_agents_base_dir(app)?.join("personas.json")) } @@ -553,10 +586,62 @@ fn merge_personas(mut stored: Vec, now: &str) -> (Vec) -> bool { + let mut changed = false; + + stored.retain_mut(|record| { + if let Some((_, original_name, original_prompt)) = RETIRED_PERSONAS + .iter() + .find(|(id, _, _)| *id == record.id) + { + if record.system_prompt == *original_prompt { + // Unmodified — safe to remove entirely. + eprintln!( + "sprout-desktop: persona-migration: removing unmodified retired persona '{}'", + record.display_name + ); + changed = true; + return false; // remove from vec + } + + // Customized — soft-deprecate. + let retired_suffix = " (retired)"; + if !record.display_name.ends_with(retired_suffix) { + eprintln!( + "sprout-desktop: persona-migration: retiring customized persona '{}' → '{} (retired)'", + record.display_name, record.display_name + ); + record.display_name = format!("{}{}", original_name, retired_suffix); + record.is_active = false; + changed = true; + } + } + true // keep non-retired records + }); + + changed +} + pub fn ensure_persona_is_active( personas: &[PersonaRecord], persona_id: &str, diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index 71dbb637b..367129319 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -1,6 +1,7 @@ use super::{ - ensure_persona_ids_are_active, ensure_persona_is_active, merge_personas, validate_pack_id, - validate_persona_activation_change, validate_persona_deletion, BUILT_IN_PERSONAS, + ensure_persona_ids_are_active, ensure_persona_is_active, merge_personas, + migrate_retired_personas, validate_pack_id, validate_persona_activation_change, + validate_persona_deletion, BUILT_IN_PERSONAS, RETIRED_PERSONAS, }; use crate::managed_agents::PersonaRecord; @@ -161,6 +162,9 @@ fn merge_personas_backfills_new_builtins_for_existing_store() { #[test] fn merge_personas_demotes_retired_builtins() { + // custom_persona uses "Custom prompt", which doesn't match the original + // retired system prompt, so the migration pass soft-deprecates rather + // than removes the record. let mut retired = custom_persona("builtin:reviewer", "Reviewer"); retired.is_builtin = true; retired.is_active = true; @@ -172,9 +176,11 @@ fn merge_personas_demotes_retired_builtins() { let demoted = records .iter() .find(|record| record.id == "builtin:reviewer") - .expect("retired built-in should be retained as a custom persona"); + .expect("retired built-in should be retained as a soft-deprecated custom persona"); assert!(!demoted.is_builtin); - assert!(demoted.is_active); + // migrate_retired_personas deactivates customized retired personas. + assert!(!demoted.is_active); + assert_eq!(demoted.display_name, "Reviewer (retired)"); assert_eq!(demoted.created_at, original_created_at); assert_eq!(demoted.updated_at, "2026-04-01T00:00:00Z"); } @@ -344,3 +350,83 @@ fn pack_id_rejects_too_long() { let max_id = "a".repeat(128); assert!(validate_pack_id(&max_id).is_ok()); } + +// ── migrate_retired_personas ────────────────────────────────────────────────── + +#[test] +fn migrate_retires_unmodified_personas() { + // Simulate a store from before the Solo/Kit/Scout transition: all 6 + // retired personas with original system prompts. + let mut stored: Vec = RETIRED_PERSONAS + .iter() + .map(|(id, name, prompt)| PersonaRecord { + id: id.to_string(), + display_name: name.to_string(), + system_prompt: prompt.to_string(), + is_builtin: false, // already demoted by merge_personas + ..custom_persona(id, name) + }) + .collect(); + + let changed = migrate_retired_personas(&mut stored); + + assert!(changed); + assert!( + stored.is_empty(), + "all unmodified retired personas should be removed, got: {:?}", + stored.iter().map(|r| &r.id).collect::>() + ); +} + +#[test] +fn migrate_preserves_customized_personas() { + let mut stored = vec![PersonaRecord { + id: "builtin:researcher".to_string(), + display_name: "Researcher".to_string(), + system_prompt: "My custom research workflow with special instructions".to_string(), + is_builtin: false, + is_active: true, + ..custom_persona("builtin:researcher", "Researcher") + }]; + + let changed = migrate_retired_personas(&mut stored); + + assert!(changed); + assert_eq!(stored.len(), 1); + let record = &stored[0]; + assert_eq!(record.display_name, "Researcher (retired)"); + assert!(!record.is_active); + assert_eq!( + record.system_prompt, + "My custom research workflow with special instructions" + ); +} + +#[test] +fn migrate_is_idempotent() { + // No retired personas present — should be a no-op. + let mut stored = vec![custom_persona("custom:test", "Custom")]; + let original_len = stored.len(); + + let changed = migrate_retired_personas(&mut stored); + + assert!(!changed); + assert_eq!(stored.len(), original_len); + + // Second run with already-retired (renamed) persona — also a no-op. + let mut stored_with_retired = vec![PersonaRecord { + id: "builtin:researcher".to_string(), + display_name: "Researcher (retired)".to_string(), + system_prompt: "My custom prompt".to_string(), + is_builtin: false, + is_active: false, + ..custom_persona("builtin:researcher", "Researcher (retired)") + }]; + + let changed = migrate_retired_personas(&mut stored_with_retired); + assert!( + !changed, + "already-retired persona should not trigger another change" + ); + assert_eq!(stored_with_retired[0].display_name, "Researcher (retired)"); +} diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index 9f8b6b752..b4af3d652 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -26,6 +26,16 @@ use tauri::Manager; const LEGACY_DATA_DIR_NAME: &str = "com.wesb.sprout"; +const CANONICAL_DEV_IDENTIFIER: &str = "xyz.block.sprout.app.dev"; + +/// JSON files synced from the canonical dev data directory to worktree +/// data directories. Only data files — never `agent-pids/` or `logs/`. +const SHARED_AGENT_FILES: &[&str] = &[ + "agents/managed-agents.json", + "agents/personas.json", + "agents/teams.json", +]; + /// Known files to migrate from the legacy data directory. /// /// Agent logs and `.window-state.json` are excluded — logs are ephemeral and @@ -43,6 +53,12 @@ fn legacy_data_dir(current: &Path) -> Option { current.parent().map(|p| p.join(LEGACY_DATA_DIR_NAME)) } +/// Compute the canonical `xyz.block.sprout.app.dev` data directory path by +/// replacing the last component of the current app data directory. +fn canonical_dev_data_dir(current: &Path) -> Option { + current.parent().map(|p| p.join(CANONICAL_DEV_IDENTIFIER)) +} + /// Copy a single file from `old_dir/rel` to `new_dir/rel`, creating parent /// directories as needed. Skips the file if it already exists at the /// destination. @@ -121,6 +137,106 @@ pub fn migrate_legacy_data_dir(app: &tauri::AppHandle) { } } +/// Copy shared agent data files from the canonical dev data directory to +/// the current (worktree) data directory, overwriting any existing files. +/// +/// Guards: +/// - `SPROUT_SHARE_IDENTITY` must be `"1"` +/// - `SPROUT_PRIVATE_KEY` must parse as valid `nostr::Keys` +/// - The canonical dir must differ from the current dir (skip if we ARE canonical) +/// - The canonical dir must exist +/// +/// Unlike `migrate_file()`, this always overwrites — pre-existing worktree +/// data directories already have empty/default files that must be replaced. +pub fn sync_shared_agent_data(app: &tauri::AppHandle) { + // Guard: only runs when sharing identity with a worktree. + let is_shared = std::env::var("SPROUT_SHARE_IDENTITY") + .map(|v| v == "1") + .unwrap_or(false); + if !is_shared { + return; + } + + // Guard: SPROUT_PRIVATE_KEY must be a valid nostr key. + let has_valid_key = std::env::var("SPROUT_PRIVATE_KEY") + .ok() + .and_then(|k| k.parse::().ok()) + .is_some(); + if !has_valid_key { + eprintln!( + "sprout-desktop: shared-agent-sync: SPROUT_PRIVATE_KEY missing or invalid, skipping" + ); + return; + } + + let current_dir = match app.path().app_data_dir() { + Ok(dir) => dir, + Err(e) => { + eprintln!("sprout-desktop: shared-agent-sync: cannot resolve app data dir: {e}"); + return; + } + }; + + let canonical_dir = match canonical_dev_data_dir(¤t_dir) { + Some(dir) => dir, + None => { + eprintln!( + "sprout-desktop: shared-agent-sync: cannot compute canonical dir (no parent)" + ); + return; + } + }; + + // Guard: skip if we ARE the canonical instance. + if canonical_dir == current_dir { + return; + } + + // Guard: skip if canonical dir doesn't exist. + if !canonical_dir.exists() { + eprintln!( + "sprout-desktop: shared-agent-sync: canonical dir does not exist: {}", + canonical_dir.display() + ); + return; + } + + let mut synced = 0u32; + for rel in SHARED_AGENT_FILES { + let src = canonical_dir.join(rel); + let dst = current_dir.join(rel); + + if !src.exists() { + continue; + } + + // Ensure parent directories exist (e.g. `agents/`). + if let Some(parent) = dst.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + eprintln!( + "sprout-desktop: shared-agent-sync: failed to create {}: {e}", + parent.display() + ); + continue; + } + } + + match std::fs::copy(&src, &dst) { + Ok(_) => synced += 1, + Err(e) => { + eprintln!("sprout-desktop: shared-agent-sync: failed to copy {rel}: {e}"); + } + } + } + + if synced > 0 { + eprintln!( + "sprout-desktop: shared-agent-sync: {synced} file(s) synced from {}", + canonical_dir.display() + ); + } +} + #[cfg(test)] mod tests { use super::*; @@ -283,4 +399,116 @@ mod tests { "nsec1-already-here" ); } + + #[test] + fn canonical_dev_data_dir_replaces_last_component() { + let current = PathBuf::from( + "/Users/me/Library/Application Support/xyz.block.sprout.app.dev.my-branch", + ); + let canonical = canonical_dev_data_dir(¤t).unwrap(); + assert_eq!( + canonical, + PathBuf::from("/Users/me/Library/Application Support/xyz.block.sprout.app.dev") + ); + } + + #[test] + fn canonical_dev_data_dir_returns_none_for_root() { + // A root path has no parent — should return None. + assert!(canonical_dev_data_dir(Path::new("/")).is_none()); + } + + /// Helper: create a temp dir structure mimicking canonical + worktree layout. + /// Returns `(parent_dir_handle, canonical_dir, worktree_dir)`. + fn setup_sync_layout() -> (tempfile::TempDir, PathBuf, PathBuf) { + let parent = tempfile::tempdir().unwrap(); + let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER); + let worktree = parent.path().join("xyz.block.sprout.app.dev.my-branch"); + + // Populate canonical with agent data. + std::fs::create_dir_all(canonical.join("agents")).unwrap(); + std::fs::write( + canonical.join("agents/managed-agents.json"), + r#"[{"id":"agent-1"}]"#, + ) + .unwrap(); + std::fs::write( + canonical.join("agents/personas.json"), + r#"[{"id":"builtin:solo"}]"#, + ) + .unwrap(); + std::fs::write(canonical.join("agents/teams.json"), r#"[{"id":"team-1"}]"#).unwrap(); + + (parent, canonical, worktree) + } + + /// Helper: sync files directly (without a Tauri AppHandle) for unit testing. + /// Mirrors the core loop of `sync_shared_agent_data` but takes explicit paths. + fn sync_files(canonical: &Path, worktree: &Path) -> u32 { + let mut synced = 0u32; + for rel in SHARED_AGENT_FILES { + let src = canonical.join(rel); + let dst = worktree.join(rel); + if !src.exists() { + continue; + } + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::copy(&src, &dst).unwrap(); + synced += 1; + } + synced + } + + #[test] + fn sync_copies_files_to_fresh_worktree() { + let (_parent, canonical, worktree) = setup_sync_layout(); + + let synced = sync_files(&canonical, &worktree); + + assert_eq!(synced, 3); + assert_eq!( + std::fs::read_to_string(worktree.join("agents/managed-agents.json")).unwrap(), + r#"[{"id":"agent-1"}]"#, + ); + assert_eq!( + std::fs::read_to_string(worktree.join("agents/personas.json")).unwrap(), + r#"[{"id":"builtin:solo"}]"#, + ); + assert_eq!( + std::fs::read_to_string(worktree.join("agents/teams.json")).unwrap(), + r#"[{"id":"team-1"}]"#, + ); + } + + #[test] + fn sync_overwrites_existing_files() { + let (_parent, canonical, worktree) = setup_sync_layout(); + + // Pre-create worktree with stale/empty data (the real-world scenario). + std::fs::create_dir_all(worktree.join("agents")).unwrap(); + std::fs::write(worktree.join("agents/managed-agents.json"), "[]").unwrap(); + std::fs::write(worktree.join("agents/personas.json"), "[]").unwrap(); + std::fs::write(worktree.join("agents/teams.json"), "[]").unwrap(); + + let synced = sync_files(&canonical, &worktree); + + assert_eq!(synced, 3); + // Must contain canonical data, NOT the empty arrays. + assert_eq!( + std::fs::read_to_string(worktree.join("agents/managed-agents.json")).unwrap(), + r#"[{"id":"agent-1"}]"#, + ); + } + + #[test] + fn sync_skips_when_canonical_equals_current() { + let (_parent, canonical, _worktree) = setup_sync_layout(); + + // When canonical and current are the same path, nothing should be copied. + // We verify by checking that the function doesn't panic and the dir + // is unchanged — in practice this guard is in sync_shared_agent_data(). + assert_eq!(canonical, canonical); // trivially true — the real guard is `canonical_dir == current_dir` + } } From 977fb1d3328cfd7449cf9ed04f369769366f8373 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 22 May 2026 17:15:21 -0400 Subject: [PATCH 2/5] chore: bump file-size overrides for migration.rs and personas.rs Both files grew past the default 500/900-line limits with the worktree sync and retired persona migration additions. --- desktop/scripts/check-file-sizes.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index d93c83bf6..b55eab92d 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -30,7 +30,7 @@ const rules = [ // Exceptions should stay rare and temporary. Prefer splitting files instead. const overrides = new Map([ - ["src-tauri/src/managed_agents/personas.rs", 900], // built-in persona system prompts (Solo + Kit + Scout) + persona pack import/uninstall/list + uninstall safety check + ["src-tauri/src/managed_agents/personas.rs", 980], // built-in persona system prompts (Solo + Kit + Scout) + persona pack import/uninstall/list + uninstall safety check + retired persona migration (RETIRED_PERSONAS constant + migrate_retired_personas) ["src-tauri/src/managed_agents/teams.rs", 580], // built-in team registry (Kit & Scout) + merge_teams + validate_team_deletion + JSON export/import + tests ["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 ["src/app/AppShell.tsx", 815], // 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) @@ -45,6 +45,7 @@ const overrides = new Map([ ["src/features/settings/ui/SettingsView.tsx", 600], ["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav ["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 + ["src-tauri/src/migration.rs", 520], // legacy data dir migration + worktree shared-agent-data sync (SHARED_AGENT_FILES copy-with-overwrite) + tests for both ["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 ["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 ["src-tauri/src/commands/messages.rs", 510], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ From 0b56455745177c181fa58f3f44dba04779ce045f Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 22 May 2026 19:05:55 -0400 Subject: [PATCH 3/5] fix(desktop): address review findings for worktree sync + retired persona migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The worktree agent-data sync copied managed-agents.json with runtime_pid values intact, causing the worktree instance to kill the canonical instance's running agents. The retired persona migration removed unmodified records, orphaning persona_id references in managed-agents.json and teams.json. The same-dir guard used byte comparison, which fails on case-insensitive APFS. Key changes: - Scrub runtime_pid and 5 other volatile fields from copied managed-agents.json after sync (new scrub_managed_agents_runtime_state) - Always soft-deprecate retired personas (never delete), preserving the user's display name and refreshing updated_at - Use fs::canonicalize for the same-dir guard to handle symlinks and case-insensitive FS - Extract sibling_data_dir helper to DRY legacy_data_dir and canonical_dev_data_dir - Expand module docstring, SHARED_AGENT_FILES doc, and lib.rs ordering comment - Replace tautology test, expand idempotency test to 4 cases, fix clippy &mut Vec → &mut [_] lint --- desktop/scripts/check-file-sizes.mjs | 2 +- desktop/src-tauri/src/lib.rs | 4 + .../src-tauri/src/managed_agents/personas.rs | 50 ++---- .../src/managed_agents/personas/tests.rs | 67 ++++--- desktop/src-tauri/src/migration.rs | 164 ++++++++++++++---- 5 files changed, 202 insertions(+), 85 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index b55eab92d..689f862f9 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -45,7 +45,7 @@ const overrides = new Map([ ["src/features/settings/ui/SettingsView.tsx", 600], ["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav ["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 - ["src-tauri/src/migration.rs", 520], // legacy data dir migration + worktree shared-agent-data sync (SHARED_AGENT_FILES copy-with-overwrite) + tests for both + ["src-tauri/src/migration.rs", 620], // legacy data dir migration + worktree shared-agent-data sync (SHARED_AGENT_FILES copy-with-overwrite + runtime_pid scrub) + tests for both ["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 ["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 ["src-tauri/src/commands/messages.rs", 510], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index cc0ee568b..b94d26673 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -356,6 +356,10 @@ pub fn run() { // resolving identity, so the persisted key is available at the new // path on first launch after the identifier change. migration::migrate_legacy_data_dir(&app_handle); + // Sync shared agent data from the canonical dev data directory to + // this worktree's data directory. Must run after legacy migration + // (which may populate missing files) and before + // restore_managed_agents_on_launch (which reads managed-agents.json). migration::sync_shared_agent_data(&app_handle); // Resolve persisted identity key (env var → file → generate+save). diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index d50734dfb..23b5676f1 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -586,10 +586,10 @@ fn merge_personas(mut stored: Vec, now: &str) -> (Vec, now: &str) -> (Vec) -> bool { +/// Soft-deprecate retired built-in personas by appending " (retired)" to +/// their display name and marking them inactive. Never removes records — +/// the cost is 6 extra records for pre-transition users, but this +/// eliminates dangling `persona_id` references in managed-agents.json +/// and teams.json. +fn migrate_retired_personas(stored: &mut [PersonaRecord], now: &str) -> bool { let mut changed = false; - stored.retain_mut(|record| { - if let Some((_, original_name, original_prompt)) = RETIRED_PERSONAS + for record in stored.iter_mut() { + if let Some((_, _original_name, original_prompt)) = RETIRED_PERSONAS .iter() .find(|(id, _, _)| *id == record.id) { - if record.system_prompt == *original_prompt { - // Unmodified — safe to remove entirely. - eprintln!( - "sprout-desktop: persona-migration: removing unmodified retired persona '{}'", - record.display_name - ); - changed = true; - return false; // remove from vec - } - - // Customized — soft-deprecate. let retired_suffix = " (retired)"; if !record.display_name.ends_with(retired_suffix) { + let was_unmodified = record.system_prompt == *original_prompt; eprintln!( - "sprout-desktop: persona-migration: retiring customized persona '{}' → '{} (retired)'", - record.display_name, record.display_name + "sprout-desktop: persona-migration: retiring {} persona '{}' → '{} (retired)'", + if was_unmodified { "unmodified" } else { "customized" }, + record.display_name, + record.display_name, ); - record.display_name = format!("{}{}", original_name, retired_suffix); + record.display_name = format!("{}{}", record.display_name, retired_suffix); record.is_active = false; + record.updated_at = now.to_string(); changed = true; } } - true // keep non-retired records - }); + } changed } diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index 367129319..5a3b34576 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -355,6 +355,7 @@ fn pack_id_rejects_too_long() { #[test] fn migrate_retires_unmodified_personas() { + let now = "2026-04-01T00:00:00Z"; // Simulate a store from before the Solo/Kit/Scout transition: all 6 // retired personas with original system prompts. let mut stored: Vec = RETIRED_PERSONAS @@ -368,52 +369,64 @@ fn migrate_retires_unmodified_personas() { }) .collect(); - let changed = migrate_retired_personas(&mut stored); + let changed = migrate_retired_personas(&mut stored, now); assert!(changed); + assert_eq!( + stored.len(), + RETIRED_PERSONAS.len(), + "all retired personas should be soft-deprecated, not removed", + ); + assert!( + stored.iter().all(|r| r.display_name.ends_with(" (retired)")), + "all retired personas should have ' (retired)' suffix", + ); assert!( - stored.is_empty(), - "all unmodified retired personas should be removed, got: {:?}", - stored.iter().map(|r| &r.id).collect::>() + stored.iter().all(|r| !r.is_active), + "all retired personas should be inactive", + ); + assert!( + stored.iter().all(|r| r.updated_at == now), + "all retired personas should have refreshed updated_at", ); } #[test] fn migrate_preserves_customized_personas() { + let now = "2026-04-01T00:00:00Z"; let mut stored = vec![PersonaRecord { id: "builtin:researcher".to_string(), - display_name: "Researcher".to_string(), + display_name: "My Researcher".to_string(), system_prompt: "My custom research workflow with special instructions".to_string(), is_builtin: false, is_active: true, - ..custom_persona("builtin:researcher", "Researcher") + ..custom_persona("builtin:researcher", "My Researcher") }]; - let changed = migrate_retired_personas(&mut stored); + let changed = migrate_retired_personas(&mut stored, now); assert!(changed); assert_eq!(stored.len(), 1); let record = &stored[0]; - assert_eq!(record.display_name, "Researcher (retired)"); + assert_eq!(record.display_name, "My Researcher (retired)"); assert!(!record.is_active); assert_eq!( record.system_prompt, "My custom research workflow with special instructions" ); + assert_eq!(record.updated_at, now); } #[test] fn migrate_is_idempotent() { - // No retired personas present — should be a no-op. - let mut stored = vec![custom_persona("custom:test", "Custom")]; - let original_len = stored.len(); - - let changed = migrate_retired_personas(&mut stored); + let now = "2026-04-01T00:00:00Z"; - assert!(!changed); - assert_eq!(stored.len(), original_len); + // 1. Non-retired persona — no-op. + let mut stored = vec![custom_persona("custom:test", "Custom")]; + assert!(!migrate_retired_personas(&mut stored, now)); + assert_eq!(stored.len(), 1); - // Second run with already-retired (renamed) persona — also a no-op. + // 2. Already-retired persona (display_name ends with " (retired)") — no-op. let mut stored_with_retired = vec![PersonaRecord { id: "builtin:researcher".to_string(), display_name: "Researcher (retired)".to_string(), @@ -422,11 +435,25 @@ fn migrate_is_idempotent() { is_active: false, ..custom_persona("builtin:researcher", "Researcher (retired)") }]; - - let changed = migrate_retired_personas(&mut stored_with_retired); assert!( - !changed, + !migrate_retired_personas(&mut stored_with_retired, now), "already-retired persona should not trigger another change" ); - assert_eq!(stored_with_retired[0].display_name, "Researcher (retired)"); + + // 3. Retired persona still marked is_builtin: true (pre-demotion). + // migrate_retired_personas should still soft-deprecate it. + let mut stored_pre_demotion = vec![PersonaRecord { + id: "builtin:reviewer".to_string(), + display_name: "Reviewer".to_string(), + system_prompt: "Custom review prompt".to_string(), + is_builtin: true, + is_active: true, + ..custom_persona("builtin:reviewer", "Reviewer") + }]; + assert!(migrate_retired_personas(&mut stored_pre_demotion, now)); + assert_eq!(stored_pre_demotion[0].display_name, "Reviewer (retired)"); + assert!(!stored_pre_demotion[0].is_active); + + // 4. Run again on result of (3) — should be no-op. + assert!(!migrate_retired_personas(&mut stored_pre_demotion, now)); } diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index b4af3d652..723885b2f 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -1,25 +1,15 @@ -//! Per-file migration from the legacy `com.wesb.sprout` app data directory -//! to the current `xyz.block.sprout.app` directory. +//! Data migrations and worktree sync for the Sprout desktop app. //! -//! On each launch, for every known file in [`LEGACY_FILES`]: -//! - If the file **already exists** at the new path → skip (it's its own guard). -//! - If the file exists at the old path but not the new → copy it over. +//! **Legacy migration** (`migrate_legacy_data_dir`): One-time, idempotent +//! per-file copy from the legacy `com.wesb.sprout` app data directory to +//! the current directory. Runs on every launch; each file's existence at +//! the destination is its own guard. //! -//! Each `std::fs::copy` is atomic per-file, so there is no partial-migration -//! problem and no sentinel file is needed. -//! -//! The legacy directory is intentionally **not** deleted — users can clean it -//! up manually once they're satisfied everything works. -//! -//! Errors are logged but never fatal; the app must still start even if -//! individual file copies fail. -//! -//! **Note on dev/prod side-by-side:** Both the production build -//! (`xyz.block.sprout.app`) and the dev build (`xyz.block.sprout.app.dev`) -//! will attempt to migrate from the same legacy `com.wesb.sprout` directory, -//! resulting in duplicated identity keys. To avoid this, set the -//! `SPROUT_PRIVATE_KEY` env var when running the dev build — this bypasses -//! file-based identity resolution entirely. +//! **Worktree sync** (`sync_shared_agent_data`): Per-launch copy-with- +//! overwrite of agent data files from the canonical dev data directory +//! (`xyz.block.sprout.app.dev`) to the current worktree data directory. +//! Only runs when `SPROUT_SHARE_IDENTITY=1` and `SPROUT_PRIVATE_KEY` is +//! set. Overwrites on every launch so worktree data stays current. use std::path::{Path, PathBuf}; use tauri::Manager; @@ -29,7 +19,15 @@ const LEGACY_DATA_DIR_NAME: &str = "com.wesb.sprout"; const CANONICAL_DEV_IDENTIFIER: &str = "xyz.block.sprout.app.dev"; /// JSON files synced from the canonical dev data directory to worktree -/// data directories. Only data files — never `agent-pids/` or `logs/`. +/// data directories. This is the agent-data subset of [`LEGACY_FILES`]; +/// `identity.key` is deliberately excluded because worktree instances +/// receive their identity via the `SPROUT_PRIVATE_KEY` env var. +/// Only data files — never `agent-pids/` or `logs/`. +/// +/// NOTE: `agents/packs/` is intentionally excluded — recursive directory +/// sync is out of scope. Pack personas will appear in the worktree but +/// agents with `persona_pack_path` may fail if the ACP reads pack files +/// at runtime. Install packs in the worktree separately if needed. const SHARED_AGENT_FILES: &[&str] = &[ "agents/managed-agents.json", "agents/personas.json", @@ -47,16 +45,16 @@ const LEGACY_FILES: &[&str] = &[ "agents/teams.json", ]; -/// Compute the legacy `com.wesb.sprout` data directory path by replacing the -/// last component of the current app data directory. +fn sibling_data_dir(current: &Path, name: &str) -> Option { + current.parent().map(|p| p.join(name)) +} + fn legacy_data_dir(current: &Path) -> Option { - current.parent().map(|p| p.join(LEGACY_DATA_DIR_NAME)) + sibling_data_dir(current, LEGACY_DATA_DIR_NAME) } -/// Compute the canonical `xyz.block.sprout.app.dev` data directory path by -/// replacing the last component of the current app data directory. fn canonical_dev_data_dir(current: &Path) -> Option { - current.parent().map(|p| p.join(CANONICAL_DEV_IDENTIFIER)) + sibling_data_dir(current, CANONICAL_DEV_IDENTIFIER) } /// Copy a single file from `old_dir/rel` to `new_dir/rel`, creating parent @@ -137,6 +135,40 @@ pub fn migrate_legacy_data_dir(app: &tauri::AppHandle) { } } +/// After copying managed-agents.json from the canonical dir, remove +/// process-local runtime state that would cause the worktree instance +/// to kill the canonical instance's running agents. +fn scrub_managed_agents_runtime_state(path: &Path) { + let Ok(content) = std::fs::read_to_string(path) else { return }; + let Ok(mut records) = serde_json::from_str::>(&content) else { return }; + + const RUNTIME_FIELDS: &[&str] = &[ + "runtime_pid", + "last_error", + "last_exit_code", + "last_stopped_at", + "last_started_at", + "backend_agent_id", + ]; + + let mut scrubbed_any = false; + for record in &mut records { + if let Some(obj) = record.as_object_mut() { + for field in RUNTIME_FIELDS { + if obj.remove(*field).is_some() { + scrubbed_any = true; + } + } + } + } + + if scrubbed_any { + if let Ok(bytes) = serde_json::to_vec_pretty(&records) { + let _ = std::fs::write(path, bytes); + } + } +} + /// Copy shared agent data files from the canonical dev data directory to /// the current (worktree) data directory, overwriting any existing files. /// @@ -188,7 +220,10 @@ pub fn sync_shared_agent_data(app: &tauri::AppHandle) { }; // Guard: skip if we ARE the canonical instance. - if canonical_dir == current_dir { + // Use canonicalize to handle case-insensitive FS and symlinks. + let current_canonical = std::fs::canonicalize(¤t_dir).unwrap_or_else(|_| current_dir.clone()); + let source_canonical = std::fs::canonicalize(&canonical_dir).unwrap_or_else(|_| canonical_dir.clone()); + if current_canonical == source_canonical { return; } @@ -229,6 +264,13 @@ pub fn sync_shared_agent_data(app: &tauri::AppHandle) { } } + // Scrub runtime-local state from the copied managed-agents.json to + // prevent the worktree from killing the canonical instance's running agents. + let managed_agents_dst = current_dir.join("agents/managed-agents.json"); + if managed_agents_dst.exists() { + scrub_managed_agents_runtime_state(&managed_agents_dst); + } + if synced > 0 { eprintln!( "sprout-desktop: shared-agent-sync: {synced} file(s) synced from {}", @@ -504,11 +546,67 @@ mod tests { #[test] fn sync_skips_when_canonical_equals_current() { - let (_parent, canonical, _worktree) = setup_sync_layout(); + let parent = tempfile::tempdir().unwrap(); + let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER); + + // When the current dir IS the canonical dir, canonical_dev_data_dir + // returns the exact same path. The equality guard in + // sync_shared_agent_data detects this and skips the sync entirely, + // so data is never clobbered. + let resolved = canonical_dev_data_dir(&canonical).unwrap(); + assert_eq!(resolved, canonical); + } + + #[test] + fn canonical_dev_data_dir_for_canonical_instance_returns_self() { + // When the current app data dir IS the canonical dev identifier, + // canonical_dev_data_dir returns the exact same path. The caller + // (sync_shared_agent_data) uses this equality to skip the sync. + // The env-var guards (SPROUT_SHARE_IDENTITY, SPROUT_PRIVATE_KEY) + // require a live Tauri AppHandle and are covered by integration + // testing only. + let current = PathBuf::from( + "/Users/me/Library/Application Support/xyz.block.sprout.app.dev", + ); + let canonical = canonical_dev_data_dir(¤t).unwrap(); + assert_eq!(canonical, current); + } + + #[test] + fn sync_scrubs_runtime_pid_from_managed_agents() { + let (_parent, canonical, worktree) = setup_sync_layout(); + + // Write canonical managed-agents.json with runtime state fields. + std::fs::write( + canonical.join("agents/managed-agents.json"), + serde_json::to_string_pretty(&serde_json::json!([{ + "id": "agent-1", + "name": "Test Agent", + "runtime_pid": 12345, + "last_error": "some error", + "last_exit_code": 1, + "last_stopped_at": "2026-01-01T00:00:00Z", + "last_started_at": "2026-01-01T00:00:00Z", + "backend_agent_id": "ba-123" + }])) + .unwrap(), + ) + .unwrap(); - // When canonical and current are the same path, nothing should be copied. - // We verify by checking that the function doesn't panic and the dir - // is unchanged — in practice this guard is in sync_shared_agent_data(). - assert_eq!(canonical, canonical); // trivially true — the real guard is `canonical_dir == current_dir` + sync_files(&canonical, &worktree); + scrub_managed_agents_runtime_state(&worktree.join("agents/managed-agents.json")); + + let content = std::fs::read_to_string(worktree.join("agents/managed-agents.json")).unwrap(); + let records: Vec = serde_json::from_str(&content).unwrap(); + let agent = &records[0]; + + assert_eq!(agent["id"], "agent-1"); + assert_eq!(agent["name"], "Test Agent"); + assert!(agent.get("runtime_pid").is_none(), "runtime_pid should be scrubbed"); + assert!(agent.get("last_error").is_none(), "last_error should be scrubbed"); + assert!(agent.get("last_exit_code").is_none(), "last_exit_code should be scrubbed"); + assert!(agent.get("last_stopped_at").is_none(), "last_stopped_at should be scrubbed"); + assert!(agent.get("last_started_at").is_none(), "last_started_at should be scrubbed"); + assert!(agent.get("backend_agent_id").is_none(), "backend_agent_id should be scrubbed"); } } From bc22b1752dba451eeb2f723e22910be507d5b620 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 22 May 2026 19:22:21 -0400 Subject: [PATCH 4/5] style: cargo fmt --- .../src-tauri/src/managed_agents/personas.rs | 11 +++-- .../src/managed_agents/personas/tests.rs | 4 +- desktop/src-tauri/src/migration.rs | 49 ++++++++++++++----- 3 files changed, 46 insertions(+), 18 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index 23b5676f1..3b916171b 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -606,16 +606,19 @@ fn migrate_retired_personas(stored: &mut [PersonaRecord], now: &str) -> bool { let mut changed = false; for record in stored.iter_mut() { - if let Some((_, _original_name, original_prompt)) = RETIRED_PERSONAS - .iter() - .find(|(id, _, _)| *id == record.id) + if let Some((_, _original_name, original_prompt)) = + RETIRED_PERSONAS.iter().find(|(id, _, _)| *id == record.id) { let retired_suffix = " (retired)"; if !record.display_name.ends_with(retired_suffix) { let was_unmodified = record.system_prompt == *original_prompt; eprintln!( "sprout-desktop: persona-migration: retiring {} persona '{}' → '{} (retired)'", - if was_unmodified { "unmodified" } else { "customized" }, + if was_unmodified { + "unmodified" + } else { + "customized" + }, record.display_name, record.display_name, ); diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index 5a3b34576..d13ec0af5 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -378,7 +378,9 @@ fn migrate_retires_unmodified_personas() { "all retired personas should be soft-deprecated, not removed", ); assert!( - stored.iter().all(|r| r.display_name.ends_with(" (retired)")), + stored + .iter() + .all(|r| r.display_name.ends_with(" (retired)")), "all retired personas should have ' (retired)' suffix", ); assert!( diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index 723885b2f..96d83e07b 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -139,8 +139,12 @@ pub fn migrate_legacy_data_dir(app: &tauri::AppHandle) { /// process-local runtime state that would cause the worktree instance /// to kill the canonical instance's running agents. fn scrub_managed_agents_runtime_state(path: &Path) { - let Ok(content) = std::fs::read_to_string(path) else { return }; - let Ok(mut records) = serde_json::from_str::>(&content) else { return }; + let Ok(content) = std::fs::read_to_string(path) else { + return; + }; + let Ok(mut records) = serde_json::from_str::>(&content) else { + return; + }; const RUNTIME_FIELDS: &[&str] = &[ "runtime_pid", @@ -221,8 +225,10 @@ pub fn sync_shared_agent_data(app: &tauri::AppHandle) { // Guard: skip if we ARE the canonical instance. // Use canonicalize to handle case-insensitive FS and symlinks. - let current_canonical = std::fs::canonicalize(¤t_dir).unwrap_or_else(|_| current_dir.clone()); - let source_canonical = std::fs::canonicalize(&canonical_dir).unwrap_or_else(|_| canonical_dir.clone()); + let current_canonical = + std::fs::canonicalize(¤t_dir).unwrap_or_else(|_| current_dir.clone()); + let source_canonical = + std::fs::canonicalize(&canonical_dir).unwrap_or_else(|_| canonical_dir.clone()); if current_canonical == source_canonical { return; } @@ -565,9 +571,8 @@ mod tests { // The env-var guards (SPROUT_SHARE_IDENTITY, SPROUT_PRIVATE_KEY) // require a live Tauri AppHandle and are covered by integration // testing only. - let current = PathBuf::from( - "/Users/me/Library/Application Support/xyz.block.sprout.app.dev", - ); + let current = + PathBuf::from("/Users/me/Library/Application Support/xyz.block.sprout.app.dev"); let canonical = canonical_dev_data_dir(¤t).unwrap(); assert_eq!(canonical, current); } @@ -602,11 +607,29 @@ mod tests { assert_eq!(agent["id"], "agent-1"); assert_eq!(agent["name"], "Test Agent"); - assert!(agent.get("runtime_pid").is_none(), "runtime_pid should be scrubbed"); - assert!(agent.get("last_error").is_none(), "last_error should be scrubbed"); - assert!(agent.get("last_exit_code").is_none(), "last_exit_code should be scrubbed"); - assert!(agent.get("last_stopped_at").is_none(), "last_stopped_at should be scrubbed"); - assert!(agent.get("last_started_at").is_none(), "last_started_at should be scrubbed"); - assert!(agent.get("backend_agent_id").is_none(), "backend_agent_id should be scrubbed"); + assert!( + agent.get("runtime_pid").is_none(), + "runtime_pid should be scrubbed" + ); + assert!( + agent.get("last_error").is_none(), + "last_error should be scrubbed" + ); + assert!( + agent.get("last_exit_code").is_none(), + "last_exit_code should be scrubbed" + ); + assert!( + agent.get("last_stopped_at").is_none(), + "last_stopped_at should be scrubbed" + ); + assert!( + agent.get("last_started_at").is_none(), + "last_started_at should be scrubbed" + ); + assert!( + agent.get("backend_agent_id").is_none(), + "backend_agent_id should be scrubbed" + ); } } From 4481cd9e3e6595c8f9445b9f7605d0a9b23a4a6b Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 22 May 2026 20:03:33 -0400 Subject: [PATCH 5/5] chore: bump migration.rs file-size override to 640 cargo fmt expanded assert! blocks from single lines to multi-line, pushing the file past the 620-line limit. --- desktop/scripts/check-file-sizes.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 689f862f9..cab555d9e 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -45,7 +45,7 @@ const overrides = new Map([ ["src/features/settings/ui/SettingsView.tsx", 600], ["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav ["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 - ["src-tauri/src/migration.rs", 620], // legacy data dir migration + worktree shared-agent-data sync (SHARED_AGENT_FILES copy-with-overwrite + runtime_pid scrub) + tests for both + ["src-tauri/src/migration.rs", 640], // legacy data dir migration + worktree shared-agent-data sync (SHARED_AGENT_FILES copy-with-overwrite + runtime_pid scrub) + tests for both ["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 ["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 ["src-tauri/src/commands/messages.rs", 510], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ