diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index ac4a4313c..fb371cab9 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -22,6 +22,11 @@ pub struct AppState { /// writes are performed over the plain WebSocket (REQ/EVENT) instead. /// Set by `apply_workspace`. See docs/SPROUT_LITE_MODE.md. pub serverless: std::sync::atomic::AtomicBool, + /// Set true once the frontend has called `apply_workspace` with the active + /// workspace's config (relay URL + serverless flag). Managed-agent restore + /// at startup waits for this so serverless agents launch with the CURRENT + /// workspace relays/mode instead of a stale value frozen in their record. + pub workspace_applied: std::sync::atomic::AtomicBool, /// Persistent WebSocket pool for serverless mode — one long-lived /// connection per relay, reused for all queries/publishes (avoids the /// connect-per-op storm that public relays rate-limit). @@ -79,6 +84,7 @@ pub fn build_app_state() -> AppState { .unwrap_or_else(|_| reqwest::Client::new()), relay_url_override: Mutex::new(None), serverless: std::sync::atomic::AtomicBool::new(false), + workspace_applied: std::sync::atomic::AtomicBool::new(false), relay_pool: std::sync::Arc::new(crate::ws_pool::RelayPool::new()), managed_agents_store_lock: Mutex::new(()), channel_templates_store_lock: Mutex::new(()), diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 712e38a10..52c3eea29 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -53,6 +53,12 @@ pub fn apply_workspace( serverless.unwrap_or(false), std::sync::atomic::Ordering::Relaxed, ); + // Signal launch-time agent restore that the workspace config is now live, + // so serverless agents spawn with the current relay list/mode (not a stale + // value frozen in their record). + state + .workspace_applied + .store(true, std::sync::atomic::Ordering::Relaxed); // Drop any pooled relay connections from the previous workspace so we don't // reuse a socket authed to a different relay/identity. diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 716034d53..3f1d62f23 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -26,6 +26,32 @@ pub fn restore_managed_agents_on_launch( let state = app.state::(); + // Wait (bounded) for the frontend to apply the active workspace before + // launching agents. Agent records freeze a relay URL at creation time; in + // serverless mode we must instead use the CURRENT workspace relays (see + // `spawn_agent_child`), which are only known after `apply_workspace` runs. + // Launching first would spawn agents pointed at stale/removed relays (e.g. + // paid relays that 403) with no healthy relay to fail over to. Poll up to + // ~15s; if the frontend never applies a workspace (headless/CI), proceed + // anyway so server-mode setups still restore. + { + use std::sync::atomic::Ordering as O; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); + while !state.workspace_applied.load(O::Relaxed) { + if shutdown_started.load(Ordering::SeqCst) { + return Ok(()); + } + if std::time::Instant::now() >= deadline { + eprintln!( + "sprout-desktop: workspace not applied after 15s; \ + restoring agents with record defaults" + ); + break; + } + std::thread::sleep(std::time::Duration::from_millis(150)); + } + } + // ── Phase A (under lock): housekeeping + collect agents to restore ── let agents_to_start: Vec; { diff --git a/justfile b/justfile index 2b2c5f853..8763224b4 100644 --- a/justfile +++ b/justfile @@ -118,7 +118,22 @@ _ensure-sidecar-stubs: set -euo pipefail TARGET=$(rustc -vV | sed -n 's|host: ||p') mkdir -p desktop/src-tauri/binaries - for bin in sprout-acp sprout-mcp-server sprout-agent sprout-dev-mcp git-credential-nostr sprout; do + # sprout-acp and sprout are REAL sidecars: serverless agents launch the ACP + # harness (sprout-acp) which shells out to the sprout CLI to post replies. + # A 0-byte stub here makes managed agents silently fail to launch/reply in + # `just dev`. Build them for real (only if missing or empty so we don't + # rebuild every dev start); the rest are genuine stubs (server-only paths). + for bin in sprout-acp sprout; do + f="desktop/src-tauri/binaries/${bin}-${TARGET}" + if [[ ! -s "$f" ]]; then + crate=$([[ "$bin" == "sprout" ]] && echo sprout-cli || echo "$bin") + echo "building real sidecar: $bin (crate $crate)" + cargo build -p "$crate" + cp "target/debug/${bin}" "$f" + chmod +x "$f" + fi + done + for bin in sprout-mcp-server sprout-agent sprout-dev-mcp git-credential-nostr; do touch "desktop/src-tauri/binaries/${bin}-${TARGET}" done