feat(desktop): lazily activate workspace agents on visit

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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
Matt Toohey
2026-07-16 12:16:13 +10:00
committed by npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
parent 03f03e8185
commit 2e2babb969
7 changed files with 307 additions and 135 deletions

View File

@@ -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,

View File

@@ -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<Keys>,
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<Option<String>>,
/// 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<HashSet<String>>,
/// 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<Keys> {
}
}
/// 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> {
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(()),

View File

@@ -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<String>,
repos_dir: Option<String>,
agent_managed_profiles: Option<bool>,
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::<AppState>();
@@ -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::<AppState>();
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::<AppState>()
.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::<AppState>();
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::<AppState>();
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::<AppState>();
#[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(())
}

View File

@@ -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.

View File

@@ -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<String>,
relay_url: &str,
) -> Option<RelayActivation> {
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::<AppState>();
// ── 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<super::ManagedAgentRecord>;
{
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<u32> = 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<u32> = 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<String> = 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"));
}
}

View File

@@ -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.

View File

@@ -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.
///