From 12241a5fda205fec79edd6b4270124511cf54d6a Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Wed, 3 Jun 2026 06:40:57 +1000 Subject: [PATCH] fix(serverless): launch agents with current workspace relays + real dev sidecars Agent restore at startup raced ahead of the frontend applying the active workspace, so serverless agents launched with the stale relay list frozen into their record at creation time (including paid relays that 403) and one even ran in server mode. When the only healthy relay rate-limited a reply, there was no failover and the reply silently vanished. - AppState.workspace_applied: agent restore now waits (bounded 15s) for the frontend to call apply_workspace before launching, so agents inherit the CURRENT workspace relays + serverless mode instead of record defaults. - apply_workspace sets the flag once config is live. - justfile _ensure-sidecar-stubs builds REAL sprout-acp + sprout sidecars (serverless agents need them) instead of 0-byte stubs that silently fail to launch/reply under just dev. --- desktop/src-tauri/src/app_state.rs | 6 +++++ desktop/src-tauri/src/commands/workspace.rs | 6 +++++ .../src-tauri/src/managed_agents/restore.rs | 26 +++++++++++++++++++ justfile | 17 +++++++++++- 4 files changed, 54 insertions(+), 1 deletion(-) 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