From 2e2babb969a6d4ad4a096adbf5fd7d62ac619158 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 16 Jul 2026 12:16:13 +1000 Subject: [PATCH] feat(desktop): lazily activate workspace agents on visit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract agent activation out of the one-shot boot restore into a relay-filtered step that runs on every workspace apply (Phase 2 of lazy multi-workspace agents, building on the Phase 1 relay pinning): - Rename restore_managed_agents_on_launch to activate_workspace_agents, taking the applied workspace relay and starting only local start-on-launch agents pinned to that relay (normalized match; a blank pin matches the visited workspace, mirroring the effective_agent_relay_url defense-in-depth fallback). - Call it from every apply_workspace instead of only the first — boot restore becomes the session's first activation. The one-shot managed_agent_restore_pending flag now gates only the mesh-llm Share Compute restore. - Track activated relays in AppState (activated_agent_relays) so each workspace activates at most once per app session: bouncing A→B→A never resurrects agents the user manually stopped in A. Nothing is stopped on switch — each workspace's agents keep running against their own relay. - Gate the boot-time repos-dir/identity-recovery safety checks behind a new session-wide managed_agent_activation_enabled flag, and run the stale-process/orphan sweeps only on the session's first activation so a later activation cannot reap a concurrent activation's not-yet-tracked children. Tested: cargo test --manifest-path desktop/src-tauri/Cargo.toml (1424 passed, incl. 7 new activation unit tests), clippy -D warnings, rustfmt, cargo check --features mesh-llm. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- desktop/scripts/check-file-sizes.mjs | 2 +- desktop/src-tauri/src/app_state.rs | 57 ++-- desktop/src-tauri/src/commands/workspace.rs | 83 ++---- desktop/src-tauri/src/lib.rs | 20 +- .../src-tauri/src/managed_agents/restore.rs | 276 ++++++++++++++++-- .../src/managed_agents/spawn_hash.rs | 2 +- desktop/src-tauri/src/migration.rs | 2 +- 7 files changed, 307 insertions(+), 135 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index bf7177e6c2..e89c5f3f7f 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -374,7 +374,7 @@ const overrides = new Map([ // +5 (1068 -> 1073): merge with main, which independently added the // managed_agent_profile_reconcile_enabled flag (field + doc + init) under // its own 1042-line override. Union of two separately approved additions. - ["src-tauri/src/app_state.rs", 1073], + ["src-tauri/src/app_state.rs", 1054], // multi-slot splitting + no-op suppression (#1309): the ReadStateManager // class grew from ~700 lines to ~1019 with the addition of // splitContextsIntoBudgetedSlots (pure fn + 5 tests), publishSplitSlots, diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 71f155b18a..70ff474d74 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -1,5 +1,5 @@ use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, io::Write, sync::{ atomic::{AtomicBool, AtomicU16}, @@ -18,26 +18,29 @@ use crate::managed_agents::ManagedAgentProcess; pub struct AppState { pub keys: Mutex, pub http_client: reqwest::Client, - /// A no-redirect client for authenticated relay media fetches (download, - /// clipboard copy, snapshot, editor). Every caller pre-validates the URL - /// origin, but the app-wide `http_client` follows redirects by default, so - /// a relay `/media/` URL returning a 3xx to an off-origin or private host - /// would forward the minted media Authorization header across origins — - /// a redirect-hop SSRF. This client treats any 3xx as a non-success - /// response (surfaced as an error) so the auth token never leaves the - /// validated relay origin. - pub media_fetch_client: reqwest::Client, /// Workspace-provided relay URL override. Set by `apply_workspace` on app /// init and takes priority over env vars and compile-time defaults. pub relay_url_override: Mutex>, - /// Set during backend setup when managed agents are eligible for launch - /// restore. `apply_workspace` consumes it after installing the workspace - /// relay and identity, so agents never start against the fallback relay. + /// One-shot gate consumed by the first `apply_workspace` after boot to + /// restore Share Compute (mesh-llm). Agent activation itself is governed + /// by `managed_agent_activation_enabled` + `activated_agent_relays` and + /// runs on every `apply_workspace`. pub managed_agent_restore_pending: AtomicBool, /// Whether desktop may repair managed-agent kind:0 profiles from its local /// records. Disabled by the agent-managed profiles experiment so an agent's /// own profile updates are not overwritten on start or restore. pub managed_agent_profile_reconcile_enabled: AtomicBool, + /// Session-wide gate for lazy agent activation. Set during backend setup + /// only when the boot-time repos-dir and identity-recovery safety checks + /// allow agents to start; when false, no `apply_workspace` may auto-start + /// agents this session. + pub managed_agent_activation_enabled: AtomicBool, + /// Normalized relay URLs whose workspaces were already activated this app + /// session. A workspace's agents lazily start on the first visit and at + /// most once per session — bouncing A→B→A must not resurrect agents the + /// user manually stopped in A. Never stops agents: switching away leaves + /// the other workspace's agents running against their own relay. + pub activated_agent_relays: Mutex>, /// One-shot gate: first `apply_workspace` runs `pin_blank_agent_relays`. pub agent_relay_stamp_pending: AtomicBool, /// Shared shutdown signal checked by launch-time agent restoration. @@ -152,27 +155,6 @@ fn identity_from_env() -> Option { } } -/// Build the no-redirect HTTP client used for authenticated relay media -/// fetches (download / copy). -/// -/// This client is a security boundary, not a convenience: it carries a minted -/// media `Authorization` header, so it MUST NOT follow redirects. A relay 3xx -/// to an off-origin or private host would otherwise forward that header across -/// origins (a redirect-hop SSRF). `redirect::Policy::none()` returns the 3xx -/// verbatim so the caller can reject it. -/// -/// Returned as a `Result` so the fail-closed invariant is testable — callers -/// must never substitute a redirect-following client on build failure. Shares -/// the localhost `resolve`/pool config with the app-wide `http_client`. -pub fn build_media_fetch_client() -> reqwest::Result { - reqwest::Client::builder() - .resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0))) - .pool_idle_timeout(std::time::Duration::from_secs(10)) - .pool_max_idle_per_host(1) - .redirect(reqwest::redirect::Policy::none()) - .build() -} - pub fn build_app_state() -> AppState { // Env var takes precedence (dev/CI). If absent, resolve_persisted_identity() // in setup() will replace the ephemeral placeholder with a persisted key. @@ -195,14 +177,11 @@ pub fn build_app_state() -> AppState { .pool_max_idle_per_host(1) .build() .unwrap_or_else(|_| reqwest::Client::new()), - media_fetch_client: build_media_fetch_client().expect( - "media_fetch_client must build with redirect::Policy::none(); a \ - redirect-following fallback would forward the minted media auth \ - header across origins (redirect-hop SSRF)", - ), relay_url_override: Mutex::new(None), managed_agent_restore_pending: AtomicBool::new(false), managed_agent_profile_reconcile_enabled: AtomicBool::new(true), + managed_agent_activation_enabled: AtomicBool::new(false), + activated_agent_relays: Mutex::new(HashSet::new()), agent_relay_stamp_pending: AtomicBool::new(true), shutdown_started: AtomicBool::new(false), managed_agent_restore_transition: Mutex::new(()), diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 6ae4543744..3622338dc6 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -5,9 +5,9 @@ use tauri::{AppHandle, Emitter, Manager, State}; use crate::app_state::AppState; use crate::managed_agents::{ - effective_repos_dir, ensure_repos_symlink, load_managed_agents, nest_dir, - rebind_agent_relay_urls, restore_managed_agents_on_launch, save_managed_agents, - stamp_blank_agent_relay_urls, try_regenerate_nest, write_persisted_repos_dir, + activate_workspace_agents, effective_repos_dir, ensure_repos_symlink, load_managed_agents, + nest_dir, rebind_agent_relay_urls, save_managed_agents, stamp_blank_agent_relay_urls, + try_regenerate_nest, write_persisted_repos_dir, }; use crate::relay; @@ -104,10 +104,10 @@ pub async fn apply_workspace( relay_url: String, nsec: Option, repos_dir: Option, - agent_managed_profiles: Option, app: AppHandle, ) -> Result<(), String> { let restore_app = app.clone(); + let activation_relay_url = relay_url.clone(); tokio::task::spawn_blocking(move || { let state = app.state::(); @@ -150,13 +150,6 @@ pub async fn apply_workspace( *keys_guard = keys; } - // Keep the backend-side reconcile guard aligned with the frontend - // experiment before launch-time restore can spawn any agents. Missing - // means the stable behavior: desktop remains authoritative. - state - .managed_agent_profile_reconcile_enabled - .store(!agent_managed_profiles.unwrap_or(false), Ordering::Release); - // ── One-shot legacy migration (non-fatal) ───────────────────────────── // Pin any blank-relay agent record to the first workspace applied // after boot — exactly what blank would have resolved to at boot @@ -200,53 +193,35 @@ pub async fn apply_workspace( .await .map_err(|e| format!("spawn_blocking failed: {e}"))??; - let state = restore_app.state::(); - let restore_pending = state + // Lazy per-workspace activation: runs on EVERY apply, and self-gates so + // each workspace's agents start at most once per app session (launch + // restore is simply the first activation). Nothing is stopped on switch — + // the previous workspace's agents keep running against their own relay. + // The one-shot restore flag is consumed only for Share Compute (mesh-llm), + // which must be up before its dependent agents spawn. + #[cfg(feature = "mesh-llm")] + let mesh_restore_pending = restore_app + .state::() .managed_agent_restore_pending .swap(false, Ordering::AcqRel); - - // The coordinator starts before React applies the selected workspace, so - // its startup publication may have used the fallback relay and placeholder - // identity. Correct it off the command path so an unavailable relay cannot - // hold the frontend on its loading gate. On initial launch, restore MeshLLM - // first so a slow stopped-status request cannot overwrite a newly restored - // serving status, then restore managed agents after the admission identity - // has been published (or the bounded publication attempt has timed out). - #[cfg(feature = "mesh-llm")] - { - let app = restore_app.clone(); - tauri::async_runtime::spawn(async move { - let state = app.state::(); - if restore_pending { - if let Err(error) = - crate::commands::mesh_llm::restore_mesh_sharing(&app, &state).await - { - eprintln!("buzz-desktop: failed to restore Share Compute: {error}"); - } - } - crate::mesh_llm::publish_current_status_once(&app, "workspace apply").await; - if restore_pending { - if let Err(error) = - restore_managed_agents_on_launch(&app, &state.shutdown_started).await - { - eprintln!("buzz-desktop: failed to restore managed agents: {error}"); - } - } - }); - } - - #[cfg(not(feature = "mesh-llm"))] - if restore_pending { - let app = restore_app.clone(); - tauri::async_runtime::spawn(async move { - let state = app.state::(); - if let Err(error) = - restore_managed_agents_on_launch(&app, &state.shutdown_started).await + let app = restore_app.clone(); + tauri::async_runtime::spawn(async move { + let state = app.state::(); + #[cfg(feature = "mesh-llm")] + if mesh_restore_pending { + if let Err(error) = crate::commands::mesh_llm::restore_mesh_sharing(&app, &state).await { - eprintln!("buzz-desktop: failed to restore managed agents: {error}"); + eprintln!("buzz-desktop: failed to restore Share Compute: {error}"); } - }); - } + } + #[cfg(feature = "mesh-llm")] + crate::mesh_llm::publish_current_status_once(&app, "workspace apply").await; + if let Err(error) = + activate_workspace_agents(&app, &state.shutdown_started, &activation_relay_url).await + { + eprintln!("buzz-desktop: failed to activate workspace agents: {error}"); + } + }); Ok(()) } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index fdefbbbe02..da60dec4f1 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -526,7 +526,7 @@ pub fn run() { // Backfill the pinned persona snapshot for any pre-existing agent // that predates the record-authoritative-spawn cutover (persona_id // set but no source_version). Must run before - // restore_managed_agents_on_launch so no agent spawns from an empty + // activate_workspace_agents so no agent spawns from an empty // snapshot. Synchronous and best-effort — a failure here must not // block launch, but a missing persona is logged loudly inside. if let Err(e) = backfill_persona_snapshots(&app_handle) { @@ -655,16 +655,22 @@ pub fn run() { }); } - // Defer launch-time agent restoration until `apply_workspace` has - // installed the active workspace relay and identity. Starting here - // would race React initialization and send agents whose saved record - // has no relay override to the localhost fallback. Preserve the - // boot-time repos and identity recovery safety gates by only marking - // restoration pending when both allow it. + // Defer agent activation until `apply_workspace` has installed a + // workspace relay and identity. Starting here would race React + // initialization and send agents whose saved record has no relay + // override to the localhost fallback. Each workspace's agents then + // lazily activate on its first visit (`activate_workspace_agents`); + // the boot restore is simply the first activation. Preserve the + // boot-time repos and identity recovery safety gates by only + // enabling activation (and the one-shot mesh-llm restore) when + // both allow it. if restore_agents && !recovery_mode { state .managed_agent_restore_pending .store(true, Ordering::Release); + state + .managed_agent_activation_enabled + .store(true, Ordering::Release); } // Periodic sweep: reap orphaned agents from dead instances every 60s. diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index e4fc18b39f..ff96902155 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -3,10 +3,11 @@ use super::relay_mesh_model_id; use super::{ find_managed_agent_mut, kill_stale_tracked_processes, load_managed_agents, load_personas, save_managed_agents, spawn_agent_child, sync_managed_agent_processes, BackendKind, - ManagedAgentProcess, + ManagedAgentProcess, ManagedAgentRecord, }; use crate::app_state::AppState; use crate::util; +use std::collections::HashSet; use std::sync::atomic::{AtomicBool, Ordering}; use tauri::Manager; @@ -15,7 +16,7 @@ type AgentSpawnResult = (String, SpawnResult); /// Backfill the pinned persona snapshot for pre-existing agents created before /// the record became the spawn source of truth. Runs once at launch, before -/// `restore_managed_agents_on_launch` spawns anything, so no agent boots from an +/// `activate_workspace_agents` spawns anything, so no agent boots from an /// empty snapshot. /// /// Only records with a `persona_id` but no `persona_source_version` are touched. @@ -73,15 +74,73 @@ pub fn backfill_persona_snapshots(app: &tauri::AppHandle) -> Result<(), String> Ok(()) } -/// Restore managed agents that were running before the app was closed. +/// Scope of a relay activation granted by [`begin_relay_activation`]. +pub(crate) struct RelayActivation { + /// True only for the session's very first activation (the boot restore): + /// the one-shot stale-process and orphan sweeps run only then. Later + /// activations must not re-sweep — a concurrent activation's freshly + /// spawned children are not yet tracked and would read as orphans. + pub run_boot_sweeps: bool, +} + +/// Record `relay_url` as activated for this app session, deciding whether the +/// caller may start its agents. +/// +/// Returns `None` when the URL is blank or this relay was already activated +/// this session — the caller must not start agents again, so bouncing A→B→A +/// never resurrects an agent the user manually stopped in A. Activation is +/// marked at attempt time (a failed attempt is not retried until relaunch), +/// matching the old one-shot restore flag. Relays are keyed normalized +/// (trailing slash, scheme/host case), so cosmetic URL differences cannot +/// re-activate a workspace. +pub(crate) fn begin_relay_activation( + activated_relays: &mut HashSet, + relay_url: &str, +) -> Option { + let normalized = crate::relay::normalize_relay_url(relay_url); + if normalized.is_empty() { + return None; + } + let run_boot_sweeps = activated_relays.is_empty(); + if !activated_relays.insert(normalized) { + return None; + } + Some(RelayActivation { run_boot_sweeps }) +} + +/// Whether `record` auto-starts when `workspace_relay_url` is activated: +/// a local start-on-launch agent pinned to this relay. A blank pin matches +/// the workspace being activated — mirroring `effective_agent_relay_url`'s +/// defense-in-depth fallback — so a record that somehow escaped relay +/// stamping still auto-starts against the relay it resolves to at spawn. +pub(crate) fn record_activates_on_relay( + record: &ManagedAgentRecord, + workspace_relay_url: &str, +) -> bool { + if !record.start_on_app_launch || record.backend != BackendKind::Local { + return false; + } + let pinned = record.relay_url.trim(); + pinned.is_empty() || crate::relay::relay_urls_equivalent(pinned, workspace_relay_url) +} + +/// Lazily activate a workspace's managed agents. +/// +/// Called from every `apply_workspace`: starts local `start_on_app_launch` +/// agents pinned to `workspace_relay_url` that are not already running — the +/// launch restore is simply the session's first activation. Each workspace +/// activates at most once per app session ([`begin_relay_activation`]), and +/// nothing is ever stopped here: switching away leaves the previous +/// workspace's agents running against their own relay. /// /// Split into three phases to minimise lock contention with the frontend: /// A (under lock): sync process state, cleanup, collect agents to start /// B (no locks): resolve commands and spawn processes in parallel /// C (re-lock): write back PIDs and status to records on disk -pub async fn restore_managed_agents_on_launch( +pub async fn activate_workspace_agents( app: &tauri::AppHandle, shutdown_started: &AtomicBool, + workspace_relay_url: &str, ) -> Result<(), String> { if shutdown_started.load(Ordering::SeqCst) { return Ok(()); @@ -89,7 +148,27 @@ pub async fn restore_managed_agents_on_launch( let state = app.state::(); - // ── Phase A (under lock): housekeeping + collect agents to restore ── + // Session-wide boot gate: repos-dir resolution and identity recovery are + // decided once at launch. When either fails closed, no workspace may + // auto-start agents this session. + if !state + .managed_agent_activation_enabled + .load(Ordering::Acquire) + { + return Ok(()); + } + + let Some(activation) = ({ + let mut activated_relays = state + .activated_agent_relays + .lock() + .map_err(|error| error.to_string())?; + begin_relay_activation(&mut activated_relays, workspace_relay_url) + }) else { + return Ok(()); + }; + + // ── Phase A (under lock): housekeeping + collect agents to activate ── let mut agents_to_start: Vec; { let _store_guard = state @@ -111,40 +190,51 @@ pub async fn restore_managed_agents_on_launch( &mut runtimes, &super::current_instance_id(app), ); - changed |= - kill_stale_tracked_processes(&mut records, &runtimes, &super::current_instance_id(app)); - let tracked_pids: Vec = records - .iter() - .filter_map(|r| r.runtime_pid) - .chain(runtimes.values().map(|rt| rt.child.id())) - .collect(); - super::sweep_orphaned_agent_processes(app, &tracked_pids); + // One-shot boot cleanup: previous-session leftovers and orphans are + // swept only on the session's first activation. Later activations + // must not sweep — a concurrent activation's or UI start's freshly + // spawned children are not yet in the tracked set and would be killed + // as orphans. + if activation.run_boot_sweeps { + changed |= kill_stale_tracked_processes( + &mut records, + &runtimes, + &super::current_instance_id(app), + ); - // System-wide sweep: enumerate all user processes and kill any known - // agent binaries not tracked by this session. Catches orphans whose - // PID files were already cleaned up (e.g. agent workers in their own - // process group whose parent harness exited). - super::sweep_system_agent_processes(&super::current_instance_id(app), &tracked_pids); + let tracked_pids: Vec = records + .iter() + .filter_map(|r| r.runtime_pid) + .chain(runtimes.values().map(|rt| rt.child.id())) + .collect(); + super::sweep_orphaned_agent_processes(app, &tracked_pids); - // Dead-instance reaping: find agents belonging to Buzz instances - // whose desktop process is no longer running and reap them. - super::reap_dead_instance_agents(&super::current_instance_id(app), &tracked_pids); + // System-wide sweep: enumerate all user processes and kill any known + // agent binaries not tracked by this session. Catches orphans whose + // PID files were already cleaned up (e.g. agent workers in their own + // process group whose parent harness exited). + super::sweep_system_agent_processes(&super::current_instance_id(app), &tracked_pids); - // Exact-path sweep: kill any buzz-acp process whose executable path - // matches this bundle's harness binary but is not in the tracked set. - // Complements the env-var sweep above — catches orphans that predate - // BUZZ_MANAGED_AGENT injection or lost their PID-file receipt. - // - // TODO: the three sweeps above each walk the PID table independently. - // A future consolidation should collect a single shared process snapshot - // at the top of this block and thread it through all sweep functions, - // replacing the three separate kernel enumerations. - super::sweep_untracked_bundle_harnesses(&tracked_pids); + // Dead-instance reaping: find agents belonging to Buzz instances + // whose desktop process is no longer running and reap them. + super::reap_dead_instance_agents(&super::current_instance_id(app), &tracked_pids); + + // Exact-path sweep: kill any buzz-acp process whose executable path + // matches this bundle's harness binary but is not in the tracked set. + // Complements the env-var sweep above — catches orphans that predate + // BUZZ_MANAGED_AGENT injection or lost their PID-file receipt. + // + // TODO: the three sweeps above each walk the PID table independently. + // A future consolidation should collect a single shared process snapshot + // at the top of this block and thread it through all sweep functions, + // replacing the three separate kernel enumerations. + super::sweep_untracked_bundle_harnesses(&tracked_pids); + } let candidates: Vec = records .iter() - .filter(|record| record.start_on_app_launch && record.backend == BackendKind::Local) + .filter(|record| record_activates_on_relay(record, workspace_relay_url)) .map(|record| record.pubkey.clone()) .collect(); @@ -380,3 +470,125 @@ fn persist_restore_error( record.last_error = Some(error); save_managed_agents(app, &records) } + +#[cfg(test)] +mod tests { + use super::{begin_relay_activation, record_activates_on_relay, ManagedAgentRecord}; + use std::collections::HashSet; + + /// Minimal record with the fields the activation filter reads. Everything + /// else takes its serde default (`start_on_app_launch` defaults to true, + /// `backend` to Local). + fn record_on_relay(relay_url: &str) -> ManagedAgentRecord { + serde_json::from_str(&format!( + r#"{{ + "pubkey": "agent-pubkey", + "name": "test-agent", + "private_key_nsec": "nsec1fake", + "relay_url": "{relay_url}", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }}"# + )) + .expect("sample record") + } + + // ── begin_relay_activation: once per relay per session ────────────────── + + #[test] + fn first_activation_of_session_runs_boot_sweeps() { + let mut activated = HashSet::new(); + + let activation = begin_relay_activation(&mut activated, "wss://relay-a.example") + .expect("first visit must activate"); + + assert!(activation.run_boot_sweeps, "boot restore sweeps orphans"); + } + + #[test] + fn second_workspace_activates_without_boot_sweeps() { + let mut activated = HashSet::new(); + begin_relay_activation(&mut activated, "wss://relay-a.example"); + + let activation = begin_relay_activation(&mut activated, "wss://relay-b.example") + .expect("a new workspace must activate"); + + assert!( + !activation.run_boot_sweeps, + "sweeps are one-shot: a later activation could kill a concurrent \ + activation's untracked children" + ); + } + + #[test] + fn revisiting_a_workspace_does_not_reactivate() { + // A→B→A: the second visit to A must not restart agents the user + // stopped there, even when the URL differs only cosmetically. + let mut activated = HashSet::new(); + begin_relay_activation(&mut activated, "wss://relay-a.example"); + begin_relay_activation(&mut activated, "wss://relay-b.example"); + + assert!(begin_relay_activation(&mut activated, "wss://relay-a.example").is_none()); + assert!( + begin_relay_activation(&mut activated, "WSS://Relay-A.Example/").is_none(), + "normalized matching: cosmetic URL differences must not re-activate" + ); + } + + #[test] + fn blank_relay_never_activates_nor_consumes_the_boot_sweep() { + let mut activated = HashSet::new(); + + assert!(begin_relay_activation(&mut activated, " ").is_none()); + + // The blank no-op must not have claimed the session's boot sweep. + let activation = begin_relay_activation(&mut activated, "wss://relay-a.example") + .expect("real relay still activates"); + assert!(activation.run_boot_sweeps); + } + + // ── record_activates_on_relay: relay-filtered candidate selection ─────── + + #[test] + fn record_activates_only_on_its_pinned_relay() { + let record = record_on_relay("wss://relay-a.example"); + + assert!(record_activates_on_relay(&record, "wss://relay-a.example")); + assert!( + record_activates_on_relay(&record, "WSS://Relay-A.Example/"), + "pin matching is normalized" + ); + assert!( + !record_activates_on_relay(&record, "wss://relay-b.example"), + "visiting another workspace must not start this agent" + ); + } + + #[test] + fn blank_pin_activates_on_the_visited_workspace() { + // Defense-in-depth for a record that escaped relay stamping: it spawns + // against the active workspace relay (`effective_agent_relay_url`), so + // it activates with the workspace being visited. + let record = record_on_relay(""); + assert!(record_activates_on_relay(&record, "wss://relay-a.example")); + } + + #[test] + fn opted_out_and_provider_records_never_activate() { + let mut record = record_on_relay("wss://relay-a.example"); + record.start_on_app_launch = false; + assert!(!record_activates_on_relay(&record, "wss://relay-a.example")); + + let mut record = record_on_relay("wss://relay-a.example"); + record.backend = super::BackendKind::Provider { + id: "blox".to_string(), + config: serde_json::Value::Null, + }; + assert!(!record_activates_on_relay(&record, "wss://relay-a.example")); + } +} diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash.rs b/desktop/src-tauri/src/managed_agents/spawn_hash.rs index 093c3068d6..4efe853cbc 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash.rs @@ -11,7 +11,7 @@ //! - Inputs mirror what a start would actually run: the start/restore paths //! re-snapshot the linked persona's prompt/model/provider/env onto the //! record immediately before spawning (`start_local_agent_with_preflight`, -//! `restore_managed_agents_on_launch`), so persona edits to those fields DO +//! `activate_workspace_agents`), so persona edits to those fields DO //! apply on a plain restart and are hashed via the same prospective //! re-snapshot. Harness command, args/mcp, env layering, and the record //! fields the spawn env writes read are hashed as spawn resolves them. diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index b0e3359e53..0bb7cc0dea 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -104,7 +104,7 @@ fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> { /// Run every data migration that must complete before identity resolution and /// agent restore. Ordering is load-bearing: `migrate_legacy_app_data_dir` must /// precede any disk read, and `sync_shared_agent_data` must precede -/// `restore_managed_agents_on_launch` (which reads `managed-agents.json`). +/// `activate_workspace_agents` (which reads `managed-agents.json`). /// Identity-dependent migrations (persona/team event signing) run separately in /// boot setup after the persisted identity is resolved. ///