From 60dacc53a73f7e5bcbd568365072f5b8fde25635 Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 11:26:03 -0400
Subject: [PATCH 01/51] feat(desktop): list private known Desktops
Signed-off-by: Logan Johnson
---
crates/buzz-core/src/desktop_profile.rs | 152 +++++++++
crates/buzz-core/src/kind.rs | 5 +
crates/buzz-core/src/lib.rs | 2 +
crates/buzz-db/src/runtime/migration.rs | 53 ++-
crates/buzz-relay/src/api/bridge.rs | 6 +-
.../src/api/desktop_profile_postgres_tests.rs | 167 ++++++++++
crates/buzz-relay/src/handlers/event.rs | 28 +-
crates/buzz-relay/src/handlers/ingest.rs | 9 +-
.../src/commands/desktop_profiles.rs | 155 +++++++++
desktop/src-tauri/src/commands/mod.rs | 2 +
desktop/src-tauri/src/lib.rs | 2 +
desktop/src/app/App.tsx | 6 +-
.../src/features/agents/desktopList.test.mjs | 306 ++++++++++++++++++
desktop/src/features/agents/desktopList.ts | 69 ++++
desktop/src/features/agents/ui/AgentsView.tsx | 2 +
.../src/features/agents/ui/KnownDesktops.tsx | 113 +++++++
desktop/src/shared/api/relayClientSession.ts | 4 +
migrations/0045_desktop_profile_fts.sql | 26 ++
schema/schema.sql | 2 +-
19 files changed, 1093 insertions(+), 16 deletions(-)
create mode 100644 crates/buzz-core/src/desktop_profile.rs
create mode 100644 crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs
create mode 100644 desktop/src-tauri/src/commands/desktop_profiles.rs
create mode 100644 desktop/src/features/agents/desktopList.test.mjs
create mode 100644 desktop/src/features/agents/desktopList.ts
create mode 100644 desktop/src/features/agents/ui/KnownDesktops.tsx
create mode 100644 migrations/0045_desktop_profile_fts.sql
diff --git a/crates/buzz-core/src/desktop_profile.rs b/crates/buzz-core/src/desktop_profile.rs
new file mode 100644
index 00000000000..697808c4677
--- /dev/null
+++ b/crates/buzz-core/src/desktop_profile.rs
@@ -0,0 +1,152 @@
+//! Owner-private display-only Desktop identity; never execution authority.
+use nostr::{nips::nip44, Event, EventBuilder, Keys, Kind, Tag};
+use serde::{Deserialize, Serialize};
+
+use crate::kind::KIND_DESKTOP_PROFILE;
+
+/// Minimal encrypted profile. IDs are installation-local within an owner/community.
+#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct DesktopProfile {
+ /// Format version.
+ pub v: u8,
+ /// Canonical relay URL, bound inside the ciphertext.
+ pub community: String,
+ /// Opaque random coordinate, not an agent key.
+ pub id: String,
+ /// Generated display name, never a hostname.
+ pub name: String,
+}
+
+/// Validate the public envelope without decrypting private content.
+pub fn validate_envelope(event: &Event) -> Result<(), &'static str> {
+ let tags: Vec<_> = event.tags.iter().map(|tag| tag.as_slice()).collect();
+ if event.kind.as_u16() as u32 != KIND_DESKTOP_PROFILE
+ || event.created_at.as_secs() > 253_402_300_799
+ || !(132..=2048).contains(&event.content.len())
+ || tags.len() != 1
+ || tags[0].len() != 2
+ || tags[0][0] != "d"
+ || !valid_id(&tags[0][1])
+ {
+ return Err("invalid Desktop profile envelope");
+ }
+ Ok(())
+}
+
+fn valid_id(id: &str) -> bool {
+ id.len() == 32
+ && id
+ .bytes()
+ .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
+}
+
+impl DesktopProfile {
+ /// Construct a generated, non-identifying name for a random coordinate.
+ pub fn new(community: String, id: String) -> Result {
+ if !valid_id(&id) || community.is_empty() || community.len() > 512 {
+ return Err("invalid Desktop profile coordinate");
+ }
+ Ok(Self {
+ v: 1,
+ name: format!("Desktop {}", &id[..8]),
+ community,
+ id,
+ })
+ }
+
+ /// Encrypt to the owner and sign the exact replaceable event.
+ pub fn sign(&self, keys: &Keys) -> Result {
+ let content = nip44::encrypt(
+ keys.secret_key(),
+ &keys.public_key(),
+ serde_json::to_string(self).map_err(|e| e.to_string())?,
+ nip44::Version::V2,
+ )
+ .map_err(|e| e.to_string())?;
+ EventBuilder::new(Kind::Custom(KIND_DESKTOP_PROFILE as u16), content)
+ .tag(Tag::identifier(&self.id))
+ .sign_with_keys(keys)
+ .map_err(|e| e.to_string())
+ }
+
+ /// Verify hash/signature, owner, scope and exact payload before display.
+ pub fn read(event: &Event, keys: &Keys, community: &str) -> Result {
+ validate_envelope(event)?;
+ event
+ .verify()
+ .map_err(|_| "invalid Desktop profile signature")?;
+ if event.pubkey != keys.public_key() {
+ return Err("foreign Desktop profile".into());
+ }
+ let plaintext = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content)
+ .map_err(|_| "Desktop profile decryption failed")?;
+ let profile: Self =
+ serde_json::from_str(&plaintext).map_err(|_| "invalid Desktop profile")?;
+ if profile
+ != Self::new(
+ community.to_owned(),
+ event.tags.identifier().unwrap_or_default().to_owned(),
+ )?
+ {
+ return Err("invalid Desktop profile payload or community".into());
+ }
+ Ok(profile)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn private_profile_roundtrip_and_untrusted_inputs() {
+ let owner = Keys::generate();
+ let profile = DesktopProfile::new("wss://one.example".into(), "a".repeat(32)).unwrap();
+ let event = profile.sign(&owner).unwrap();
+ assert_eq!(
+ DesktopProfile::read(&event, &owner, &profile.community).unwrap(),
+ profile
+ );
+ assert!(!event.content.contains(&profile.name));
+ assert!(DesktopProfile::read(&event, &Keys::generate(), &profile.community).is_err());
+ assert!(DesktopProfile::read(&event, &owner, "wss://two.example").is_err());
+ let mut tampered = event.clone();
+ tampered.content.push('x');
+ assert!(DesktopProfile::read(&tampered, &owner, &profile.community).is_err());
+ for field in ["v", "id", "name", "community", "extra"] {
+ let mut payload = serde_json::to_value(&profile).unwrap();
+ payload[field] = serde_json::json!("wrong");
+ let encrypted = nip44::encrypt(
+ owner.secret_key(),
+ &owner.public_key(),
+ payload.to_string(),
+ nip44::Version::V2,
+ )
+ .unwrap();
+ let invalid = EventBuilder::new(event.kind, encrypted)
+ .tag(Tag::identifier(&profile.id))
+ .sign_with_keys(&owner)
+ .unwrap();
+ assert!(
+ DesktopProfile::read(&invalid, &owner, &profile.community).is_err(),
+ "{field}"
+ );
+ }
+ for tags in [
+ vec![],
+ vec![Tag::identifier("bad")],
+ vec![Tag::identifier(&profile.id), Tag::identifier(&profile.id)],
+ ] {
+ let invalid = EventBuilder::new(event.kind, &event.content)
+ .tags(tags)
+ .sign_with_keys(&owner)
+ .unwrap();
+ assert!(validate_envelope(&invalid).is_err());
+ }
+ assert!(crate::kind::AUTHOR_ONLY_KINDS.contains(&KIND_DESKTOP_PROFILE));
+ assert!(crate::kind::is_parameterized_replaceable(
+ KIND_DESKTOP_PROFILE
+ ));
+ }
+}
diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs
index 4e1ab1c7f5e..bb8be1a2819 100644
--- a/crates/buzz-core/src/kind.rs
+++ b/crates/buzz-core/src/kind.rs
@@ -117,6 +117,9 @@ pub const KIND_PUSH_LEASE: u32 = 30350;
/// plus exact public projection bindings. See `docs/nips/NIP-PMA.md`.
pub const KIND_PRIVATE_MANAGED_AGENT: u32 = 30179;
+/// Owner-private encrypted Desktop identity/name, keyed by installation coordinate.
+pub const KIND_DESKTOP_PROFILE: u32 = 30180;
+
/// Kinds whose stored events are readable only by their author.
///
/// The relay must never reveal the existence, count, tags, content, schedule,
@@ -130,6 +133,7 @@ pub const AUTHOR_ONLY_KINDS: &[u32] = &[
KIND_EVENT_REMINDER,
KIND_PUSH_LEASE,
KIND_PRIVATE_MANAGED_AGENT,
+ KIND_DESKTOP_PROFILE,
];
/// Kinds that require a result-level read gate beyond the filter-layer
@@ -659,6 +663,7 @@ pub const ALL_KINDS: &[u32] = &[
KIND_MANAGED_AGENT,
KIND_TEAM_CATALOG,
KIND_PRIVATE_MANAGED_AGENT,
+ KIND_DESKTOP_PROFILE,
KIND_REPORT,
KIND_PRODUCT_FEEDBACK,
KIND_NIP29_PUT_USER,
diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs
index 36dc772da3b..fe94c0f356a 100644
--- a/crates/buzz-core/src/lib.rs
+++ b/crates/buzz-core/src/lib.rs
@@ -9,6 +9,8 @@
pub mod agent_turn_metric;
/// Channel and membership enums shared across crates.
pub mod channel;
+/// Owner-private Desktop display profiles.
+pub mod desktop_profile;
/// NIP-AE Agent Engrams — slug grammar, conversation key, d-tag derivation,
/// body parse/serialize, envelope build/validate, head selection.
pub mod engram;
diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs
index 59015125042..8cc2b3cd08d 100644
--- a/crates/buzz-db/src/runtime/migration.rs
+++ b/crates/buzz-db/src/runtime/migration.rs
@@ -702,7 +702,7 @@ mod postgres_tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);
- assert_eq!(migrations.len(), 44);
+ assert_eq!(migrations.len(), 45);
assert_eq!(migrations[0].version, 1);
assert_eq!(&*migrations[0].description, "initial schema");
assert!(migrations[0]
@@ -911,7 +911,7 @@ mod postgres_tests {
assert!(migrations[32].sql.as_str().contains("search_tsv"));
assert!(!migrations[0].sql.as_str().contains("30179"));
assert!(include_str!("../../../../schema/schema.sql")
- .contains("kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200)"));
+ .contains("kind IN (1059, 30179, 30180, 30300, 30350, 30622, 44100, 44101, 44200)"));
// Public push-gateway authority is intentionally deployment-global and
// durable: immediate revocation and hostile-relay admission cannot be
@@ -2386,7 +2386,12 @@ mod postgres_tests {
.await
.expect("insert community");
- for (marker, kind) in [(1_u8, 1_i32), (2_u8, 30_350_i32), (3_u8, 30_179_i32)] {
+ for (marker, kind) in [
+ (1_u8, 1_i32),
+ (2_u8, 30_350_i32),
+ (3_u8, 30_179_i32),
+ (4_u8, 30_180_i32),
+ ] {
sqlx::query(
"INSERT INTO events \
(community_id, id, pubkey, created_at, kind, tags, content, sig, received_at) \
@@ -2413,7 +2418,10 @@ mod postgres_tests {
.fetch_all(&pool)
.await
.expect("read pre-push search behavior");
- assert_eq!(before, vec![(1, true), (30_179, true), (30_350, true)]);
+ assert_eq!(
+ before,
+ vec![(1, true), (30_179, true), (30_180, true), (30_350, true)]
+ );
// 0014 fixes 30350 only. A brownfield database that stopped here still
// tokenized kind:30179 ciphertext — the gap 0033 closes.
@@ -2430,9 +2438,26 @@ mod postgres_tests {
.expect("read pre-0033 search behavior");
assert_eq!(
pre_0033,
- vec![(1, Some(true)), (30_179, Some(true)), (30_350, None)]
+ vec![
+ (1, Some(true)),
+ (30_179, Some(true)),
+ (30_180, Some(true)),
+ (30_350, None)
+ ]
);
+ run_migrations_through(&pool, 44)
+ .await
+ .expect("apply through 44");
+ let desktop_indexed: bool = sqlx::query_scalar(
+ "SELECT search_tsv @@ plainto_tsquery('simple', 'needle') \
+ FROM events WHERE kind = 30180",
+ )
+ .fetch_one(&pool)
+ .await
+ .expect("read pre-0045 Desktop search behavior");
+ assert!(desktop_indexed, "upgrade fixture must exercise legacy FTS");
+
run_migrations(&pool)
.await
.expect("apply remaining migrations to populated database");
@@ -2443,7 +2468,23 @@ mod postgres_tests {
.fetch_all(&pool)
.await
.expect("read post-upgrade search behavior");
- assert_eq!(after, vec![(1, Some(true)), (30_179, None), (30_350, None)]);
+ assert_eq!(
+ after,
+ vec![
+ (1, Some(true)),
+ (30_179, None),
+ (30_180, None),
+ (30_350, None)
+ ]
+ );
+ let gin_exists: bool = sqlx::query_scalar(
+ "SELECT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'events' \
+ AND indexname = 'idx_events_search_tsv' AND indexdef LIKE '%USING gin%')",
+ )
+ .fetch_one(&pool)
+ .await
+ .unwrap();
+ assert!(gin_exists, "upgrade must restore the search GIN index");
}
#[tokio::test]
diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs
index 37c549610de..816673219ef 100644
--- a/crates/buzz-relay/src/api/bridge.rs
+++ b/crates/buzz-relay/src/api/bridge.rs
@@ -3839,7 +3839,7 @@ mod postgres_tests {
/// - Redis pool points at the local dev instance for the admission check.
///
/// Returns `None` when local Postgres is not reachable.
- async fn bridge_handler_test_state() -> Option> {
+ pub(super) async fn bridge_handler_test_state() -> Option> {
let mut config = crate::config::Config::from_env().ok()?;
config.database_url = crate::test_support::database_url();
// Use the real local Redis so enforce_http_admission can pass.
@@ -4239,3 +4239,7 @@ mod postgres_tests {
);
}
}
+
+#[cfg(test)]
+#[path = "desktop_profile_postgres_tests.rs"]
+mod desktop_profile_postgres_tests;
diff --git a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs
new file mode 100644
index 00000000000..38c8d8af3b2
--- /dev/null
+++ b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs
@@ -0,0 +1,167 @@
+//! Private Desktop profiles through the production HTTP and WebSocket paths.
+use super::postgres_tests::bridge_handler_test_state;
+use super::*;
+use axum::{body::Body, http::Request};
+use buzz_core::kind::KIND_DESKTOP_PROFILE;
+use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
+use serde_json::json;
+use tower::ServiceExt;
+
+async fn post(
+ state: &Arc,
+ host: &str,
+ path: &str,
+ keys: &Keys,
+ body: Value,
+ signed: bool,
+) -> (StatusCode, Value) {
+ let mut request = Request::builder()
+ .method("POST")
+ .uri(path)
+ .header("host", host);
+ if signed {
+ let proof = EventBuilder::new(Kind::HttpAuth, "")
+ .tags([
+ Tag::parse(["u", &format!("https://{host}{path}")]).unwrap(),
+ Tag::parse(["method", "POST"]).unwrap(),
+ ])
+ .sign_with_keys(keys)
+ .unwrap();
+ request = request.header(
+ "authorization",
+ format!(
+ "Nostr {}",
+ base64::engine::general_purpose::STANDARD
+ .encode(serde_json::to_vec(&proof).unwrap())
+ ),
+ );
+ } else {
+ request = request.header("x-pubkey", keys.public_key().to_hex());
+ }
+ let response = crate::router::build_router(state.clone())
+ .oneshot(
+ request
+ .body(Body::from(serde_json::to_vec(&body).unwrap()))
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ let status = response.status();
+ let bytes = axum::body::to_bytes(response.into_body(), 1_048_576)
+ .await
+ .unwrap();
+ (status, serde_json::from_slice(&bytes).unwrap())
+}
+
+fn drain(rx: &mut tokio::sync::mpsc::Receiver) -> Vec {
+ let mut frames = vec![];
+ while let Ok(frame) = rx.try_recv() {
+ let axum::extract::ws::Message::Text(text) = frame else {
+ panic!("text frame")
+ };
+ frames.push(serde_json::from_str(&text).unwrap());
+ }
+ frames
+}
+
+#[tokio::test]
+#[ignore = "requires Postgres"]
+async fn desktop_profile_authenticated_owner_query_and_private_storage() {
+ let mut state = bridge_handler_test_state()
+ .await
+ .expect("test infrastructure");
+ Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).require_auth_token = true;
+ let host = format!("desktop-read-{}.example", uuid::Uuid::new_v4().simple());
+ let community = state
+ .db
+ .ensure_configured_community(&host)
+ .await
+ .unwrap()
+ .id;
+ let tenant = TenantContext::resolved(community, &host);
+ let owner = Keys::generate();
+ let outsider = Keys::generate();
+ let profile = buzz_core::desktop_profile::DesktopProfile::new(
+ format!("wss://{host}"),
+ uuid::Uuid::new_v4().simple().to_string(),
+ )
+ .unwrap();
+ let event = profile.sign(&owner).unwrap();
+ let (status, result) = post(&state, &host, "/events", &owner, json!(event), true).await;
+ assert_eq!(status, StatusCode::OK, "{result}");
+ assert_eq!(result["accepted"], true, "{result}");
+ // Match Desktop's actual bounded owner+kind inventory and exact-coordinate probe.
+ let own = json!([{"kinds":[KIND_DESKTOP_PROFILE], "authors":[owner.public_key().to_hex()], "limit":100}]);
+ let exact = json!([{"kinds":[KIND_DESKTOP_PROFILE], "authors":[owner.public_key().to_hex()], "#d":[profile.id], "limit":1}]);
+ for filters in [&own, &exact] {
+ let (status, rows) = post(&state, &host, "/query", &owner, filters.clone(), true).await;
+ assert_eq!(status, StatusCode::OK, "{rows}");
+ assert_eq!(rows.as_array().unwrap().len(), 1);
+ assert_eq!(rows[0]["id"], event.id.to_hex());
+ let (status, result) =
+ post(&state, &host, "/query", &outsider, filters.clone(), true).await;
+ assert_eq!(status, StatusCode::FORBIDDEN, "{result}");
+ let (status, result) = post(&state, &host, "/query", &owner, filters.clone(), false).await;
+ assert_eq!(status, StatusCode::UNAUTHORIZED, "{result}");
+ }
+ // Known IDs cannot grant an authenticated outsider read access either.
+ let known = json!([{"ids":[event.id.to_hex()], "kinds":[KIND_DESKTOP_PROFILE,1]}]);
+ let (status, rows) = post(&state, &host, "/query", &outsider, known, true).await;
+ assert_eq!(status, StatusCode::OK, "{rows}");
+ assert_eq!(rows, json!([]));
+ let other_host = format!("other-{host}");
+ state
+ .db
+ .ensure_configured_community(&other_host)
+ .await
+ .unwrap();
+ let (status, rows) = post(&state, &other_host, "/query", &owner, own.clone(), true).await;
+ assert_eq!(status, StatusCode::OK, "{rows}");
+ assert_eq!(rows, json!([]));
+ // Inspect the generated column: searching for plaintext in ciphertext proves nothing.
+ let mut tx = state.db.begin_event_write_transaction().await.unwrap();
+ let indexed: bool = sqlx::query_scalar(
+ "SELECT search_tsv IS NOT NULL FROM events WHERE id = $1 AND community_id = $2",
+ )
+ .bind(event.id.to_bytes().as_slice())
+ .bind(community.as_uuid())
+ .fetch_one(&mut *tx)
+ .await
+ .unwrap();
+ tx.rollback().await.unwrap();
+ assert!(!indexed, "private ciphertext must not enter FTS");
+ // WS REQ is the frontend transport; bind its real authenticated owner path.
+ for who in [&owner, &outsider] {
+ let (mut conn, mut rx) = crate::connection::tests::test_conn_with_auth(
+ crate::connection::AuthState::Authenticated(buzz_auth::AuthContext {
+ pubkey: who.public_key(),
+ scopes: buzz_auth::Scope::all_known(),
+ channel_ids: None,
+ auth_method: buzz_auth::AuthMethod::Nip42,
+ agent_owner_pubkey: None,
+ }),
+ );
+ Arc::get_mut(&mut conn).unwrap().tenant = tenant.clone();
+ crate::handlers::req::handle_req(
+ "desktops".into(),
+ serde_json::from_value(own.clone()).unwrap(),
+ vec![],
+ conn,
+ state.clone(),
+ )
+ .await;
+ let frames = drain(&mut rx);
+ let rows: Vec<_> = frames.iter().filter(|frame| frame[0] == "EVENT").collect();
+ if who.public_key() == owner.public_key() {
+ assert_eq!(rows.len(), 1, "{frames:?}");
+ assert_eq!(rows[0][2]["id"], event.id.to_hex());
+ assert!(frames.iter().any(|frame| frame[0] == "EOSE"), "{frames:?}");
+ } else {
+ assert!(rows.is_empty(), "{frames:?}");
+ assert!(
+ frames.iter().any(|frame| frame[0] == "CLOSED"),
+ "{frames:?}"
+ );
+ }
+ }
+}
diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs
index 66a8ff9e7c0..5ed5f1037e2 100644
--- a/crates/buzz-relay/src/handlers/event.rs
+++ b/crates/buzz-relay/src/handlers/event.rs
@@ -2207,6 +2207,15 @@ mod tests {
#[tokio::test]
async fn author_only_reminder_delivers_to_author_only() {
+ assert_author_only_fanout(buzz_core::kind::KIND_EVENT_REMINDER).await;
+ }
+
+ #[tokio::test]
+ async fn desktop_profile_delivers_to_author_only() {
+ assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_PROFILE).await;
+ }
+
+ async fn assert_author_only_fanout(kind: u32) {
let state = test_state().await;
let author_keys = Keys::generate();
@@ -2216,12 +2225,9 @@ mod tests {
// KIND_EVENT_REMINDER (30300) is in AUTHOR_ONLY_KINDS and is stored
// globally (channel_id = None), so the gate must apply independent
// of any channel-membership check.
- let reminder = EventBuilder::new(
- Kind::Custom(buzz_core::kind::KIND_EVENT_REMINDER as u16),
- "{}",
- )
- .sign_with_keys(&author_keys)
- .expect("sign reminder");
+ let reminder = EventBuilder::new(Kind::Custom(kind as u16), "{}")
+ .sign_with_keys(&author_keys)
+ .expect("sign reminder");
let stored = StoredEvent::new(reminder, None);
let author_conn = register_conn(&state, Some(author_pk));
@@ -2233,6 +2239,16 @@ mod tests {
(other_conn, "o".to_string()),
(unauthed_conn, "u".to_string()),
];
+ // Even an authenticated owner subscription in another tenant is denied.
+ assert!(filter_fanout_by_access(
+ &state,
+ buzz_core::CommunityId::from_uuid(Uuid::new_v4()),
+ &stored,
+ matches.clone(),
+ None,
+ )
+ .await
+ .is_empty());
let out = filter_fanout_by_access(
&state,
buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()),
diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs
index ee1d0312be9..613544df9d2 100644
--- a/crates/buzz-relay/src/handlers/ingest.rs
+++ b/crates/buzz-relay/src/handlers/ingest.rs
@@ -10,6 +10,7 @@ use tracing::{debug, error, info, warn};
use uuid::Uuid;
use buzz_auth::Scope;
+use buzz_core::kind::KIND_DESKTOP_PROFILE;
use buzz_core::kind::{
event_kind_u32, is_identity_archive_request_kind, is_parameterized_replaceable,
is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_AGENT_TURN_METRIC,
@@ -436,7 +437,7 @@ fn map_push_accept_error(error: super::push_lease::AcceptError) -> IngestError {
/// Returns `Err` for unknown kinds — the relay rejects them.
fn required_scope_for_kind(kind: u32, event: &Event) -> Result {
match kind {
- KIND_PROFILE => Ok(Scope::UsersWrite),
+ KIND_PROFILE | KIND_DESKTOP_PROFILE => Ok(Scope::UsersWrite),
KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite),
KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM
| KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT
@@ -657,6 +658,7 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
| KIND_TEAM
| KIND_MANAGED_AGENT
| KIND_PRIVATE_MANAGED_AGENT
+ | KIND_DESKTOP_PROFILE
| KIND_TEAM_CATALOG
// NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope).
// Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag).
@@ -2781,6 +2783,11 @@ async fn ingest_event_inner(
}
}
+ if kind_u32 == KIND_DESKTOP_PROFILE {
+ buzz_core::desktop_profile::validate_envelope(&event)
+ .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
+ }
+
if kind_u32 == KIND_EVENT_REMINDER {
validate_event_reminder(&event)
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
diff --git a/desktop/src-tauri/src/commands/desktop_profiles.rs b/desktop/src-tauri/src/commands/desktop_profiles.rs
new file mode 100644
index 00000000000..3b3017da71b
--- /dev/null
+++ b/desktop/src-tauri/src/commands/desktop_profiles.rs
@@ -0,0 +1,155 @@
+//! Durable read-only Desktop profiles, separate from persona publication queues.
+use buzz_core_pkg::desktop_profile::DesktopProfile;
+use nostr::{Event, JsonUtil};
+use rusqlite::{Connection, OptionalExtension, TransactionBehavior};
+use serde_json::{json, Value};
+use tauri::{AppHandle, State};
+
+use crate::app_state::AppState;
+use crate::managed_agents::retention::{active_retention_scope, open_retention_db, RetentionScope};
+
+fn scope(
+ app: &AppHandle,
+ state: &AppState,
+ owner: &str,
+ community: &str,
+) -> Result {
+ let scope = active_retention_scope(app, state)?;
+ if scope.owner_keys.public_key().to_hex() != owner
+ || scope.relay_url.trim_end_matches('/') != community
+ {
+ return Err("Desktop profile scope changed".into());
+ }
+ Ok(scope)
+}
+
+fn prepare(conn: &mut Connection, scope: &RetentionScope) -> Result {
+ // SQLite serializes concurrent startup/open requests across processes. The ID
+ // and exact ciphertext/signature commit together, before any network write.
+ let tx = conn
+ .transaction_with_behavior(TransactionBehavior::Immediate)
+ .map_err(|e| e.to_string())?;
+ tx.execute_batch(
+ "CREATE TABLE IF NOT EXISTS desktop_profile (
+ slot INTEGER PRIMARY KEY CHECK(slot = 1), raw TEXT NOT NULL);",
+ )
+ .map_err(|e| e.to_string())?;
+ let saved: Option = tx
+ .query_row(
+ "SELECT raw FROM desktop_profile WHERE slot = 1",
+ [],
+ |row| row.get(0),
+ )
+ .optional()
+ .map_err(|e| e.to_string())?;
+ let raw = match saved {
+ Some(saved) => saved,
+ None => {
+ let profile = DesktopProfile::new(
+ scope.relay_url.trim_end_matches('/').to_owned(),
+ uuid::Uuid::new_v4().simple().to_string(),
+ )?;
+ let raw = profile.sign(&scope.owner_keys)?.as_json();
+ tx.execute("INSERT INTO desktop_profile VALUES (1, ?1)", [&raw])
+ .map_err(|e| e.to_string())?;
+ raw
+ }
+ };
+ let event = Event::from_json(&raw).map_err(|_| "invalid saved Desktop profile")?;
+ DesktopProfile::read(
+ &event,
+ &scope.owner_keys,
+ scope.relay_url.trim_end_matches('/'),
+ )?;
+ tx.commit().map_err(|e| e.to_string())?;
+ Ok(json!({ "event": event }))
+}
+
+/// Prepare or reload the identical owner/community-local installation profile.
+#[tauri::command]
+pub fn prepare_desktop_profile(
+ app: AppHandle,
+ state: State<'_, AppState>,
+ owner: String,
+ community: String,
+) -> Result {
+ let scope = scope(&app, &state, &owner, &community)?;
+ prepare(&mut open_retention_db(&scope.db_path)?, &scope)
+}
+
+/// Authenticate and decrypt a bounded relay result before exposing any row to UI.
+#[tauri::command]
+pub fn read_desktop_profiles(
+ app: AppHandle,
+ state: State<'_, AppState>,
+ owner: String,
+ community: String,
+ events: Vec,
+) -> Result {
+ let scope = scope(&app, &state, &owner, &community)?;
+ if events.len() > 100 {
+ return Err("too many Desktop profiles".into());
+ }
+ let rows: Result, String> = events.iter().map(|event| {
+ let profile = DesktopProfile::read(event, &scope.owner_keys, &community)?;
+ Ok(json!({ "id": profile.id, "name": profile.name, "updated": event.created_at.as_secs() }))
+ }).collect();
+ Ok(json!(rows?))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::managed_agents::retention::scoped_retention_db_path;
+
+ #[test]
+ fn durable_identity_exact_retry_without_rewrites() {
+ let dir = tempfile::tempdir().unwrap();
+ let owner_keys = nostr::Keys::generate();
+ let scope = RetentionScope {
+ db_path: dir.path().join("one.db"),
+ relay_url: "wss://one.example".into(),
+ owner_keys,
+ };
+ let first = prepare(&mut open_retention_db(&scope.db_path).unwrap(), &scope).unwrap();
+ let mut reopened = open_retention_db(&scope.db_path).unwrap();
+ assert_eq!(prepare(&mut reopened, &scope).unwrap(), first);
+ // No mutable ACK state: a confirmed or failed publish leaves the same
+ // signed record available for exact retry, without another native write.
+ let accepted = prepare(&mut reopened, &scope).unwrap();
+ assert_eq!(accepted, first);
+ assert_eq!(reopened.total_changes(), 0, "no repeated native writes");
+ let other = RetentionScope {
+ db_path: dir.path().join("two.db"),
+ relay_url: scope.relay_url.clone(),
+ owner_keys: scope.owner_keys.clone(),
+ };
+ let second = prepare(&mut open_retention_db(&other.db_path).unwrap(), &other).unwrap();
+ assert_ne!(first["event"]["tags"], second["event"]["tags"]);
+ let a = scoped_retention_db_path(
+ dir.path(),
+ &scope.relay_url,
+ &scope.owner_keys.public_key().to_hex(),
+ );
+ assert_ne!(
+ a,
+ scoped_retention_db_path(
+ dir.path(),
+ "wss://two.example",
+ &scope.owner_keys.public_key().to_hex()
+ )
+ );
+ assert_ne!(
+ a,
+ scoped_retention_db_path(
+ dir.path(),
+ &scope.relay_url,
+ &nostr::Keys::generate().public_key().to_hex()
+ )
+ );
+ reopened
+ .execute("UPDATE desktop_profile SET raw = 'corrupt'", [])
+ .unwrap();
+ assert!(prepare(&mut reopened, &scope).is_err());
+ }
+}
diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs
index c8184a01031..3e3c38a3e8c 100644
--- a/desktop/src-tauri/src/commands/mod.rs
+++ b/desktop/src-tauri/src/commands/mod.rs
@@ -18,6 +18,7 @@ mod channel_templates;
mod channel_window;
mod channels;
mod clipboard;
+mod desktop_profiles;
mod dms;
mod engrams;
mod export_util;
@@ -92,6 +93,7 @@ pub use channel_templates::*;
pub use channel_window::*;
pub use channels::*;
pub use clipboard::*;
+pub use desktop_profiles::*;
pub use dms::*;
pub use engrams::*;
pub use global_agent_config::*;
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 12082a2a82e..65fe1aaa4d7 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -552,6 +552,8 @@ pub fn run() {
transfer_builderlab_community,
title_bar_double_click,
get_identity,
+ prepare_desktop_profile,
+ read_desktop_profiles,
get_nsec,
generate_backup_passphrase,
create_ncryptsec_backup,
diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx
index da0fbf65c49..90cec7b8e74 100644
--- a/desktop/src/app/App.tsx
+++ b/desktop/src/app/App.tsx
@@ -1,3 +1,4 @@
+import { DesktopListStartup } from "@/features/agents/ui/KnownDesktops";
import { isTauri } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { QueryClientProvider } from "@tanstack/react-query";
@@ -261,7 +262,10 @@ function CommunityQueryProvider({
}, [queryClient]);
return (
- {children}
+
+
+ {children}
+
);
}
diff --git a/desktop/src/features/agents/desktopList.test.mjs b/desktop/src/features/agents/desktopList.test.mjs
new file mode 100644
index 00000000000..4ffa141e0d2
--- /dev/null
+++ b/desktop/src/features/agents/desktopList.test.mjs
@@ -0,0 +1,306 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import React from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { refreshDesktopList } from "./desktopList.ts";
+import { DesktopListView } from "./ui/KnownDesktops.tsx";
+
+const scope = { owner: "owner-a", community: "wss://a.example" };
+const first = {
+ id: "signed-a",
+ pubkey: scope.owner,
+ kind: 30180,
+ tags: [["d", "desktop-a"]],
+};
+const second = { ...first, id: "signed-b", tags: [["d", "desktop-b"]] };
+function fixture() {
+ const events = new Map([[second.id, second]]);
+ const calls = [];
+ let epoch = 0;
+ const ipc = async (command, args) => {
+ assert.equal(args.owner, scope.owner);
+ assert.equal(args.community, scope.community);
+ calls.push(command);
+ if (command === "prepare_desktop_profile") return { event: first };
+ if (command === "read_desktop_profiles")
+ return args.events.map((event) => ({
+ id: event.tags[0][1],
+ name: event.tags[0][1],
+ updated: 100,
+ }));
+ };
+ const relay = {
+ getSessionEpoch: () => epoch,
+ fetchEvents: async (filter) => {
+ assert.deepEqual(filter.authors, [scope.owner]);
+ assert.deepEqual(filter.kinds, [30180]);
+ assert.ok(filter.limit <= 100);
+ return [...events.values()].filter(
+ (event) => !filter["#d"] || filter["#d"].includes(event.tags[0][1]),
+ );
+ },
+ publishEvent: async (event) => {
+ calls.push(event);
+ events.set(event.id, event);
+ },
+ };
+ return {
+ ipc,
+ relay,
+ calls,
+ events,
+ switchScope: () => {
+ epoch++;
+ },
+ };
+}
+
+test("two same-owner Desktops retained without presence; startup doesn't rewrite", async () => {
+ const f = fixture();
+ const list = await refreshDesktopList(scope, () => true, f.ipc, f.relay);
+ assert.equal(list.rows.length, 2);
+ assert.equal(list.local, "desktop-a");
+ assert.equal(list.warning, "");
+ assert.deepEqual(
+ await refreshDesktopList(scope, () => true, f.ipc, f.relay),
+ list,
+ );
+ assert.equal(f.calls.filter((call) => call === first).length, 1);
+});
+
+test("ACK loss retries the exact object, while disconnected entries remain listed", async () => {
+ const f = fixture();
+ f.relay.publishEvent = async (event) => {
+ f.calls.push(event);
+ throw Error("lost ACK");
+ };
+ for (let retry = 0; retry < 2; retry++) {
+ const list = await refreshDesktopList(scope, () => true, f.ipc, f.relay);
+ assert.equal(list.rows.length, 1);
+ assert.ok(list.warning);
+ }
+ assert.deepEqual(
+ f.calls.filter((call) => typeof call === "object"),
+ [first, first],
+ );
+ assert.ok(!f.calls.includes("acknowledge_desktop_profile"));
+});
+
+test("owner/community switch fences prepared bytes and late ACKs", async () => {
+ for (const boundary of ["prepare_desktop_profile", "publish"]) {
+ const f = fixture();
+ const ipc = async (command, args) => {
+ const result = await f.ipc(command, args);
+ if (command === boundary) f.switchScope();
+ return result;
+ };
+ if (boundary === "publish")
+ f.relay.publishEvent = async () => f.switchScope();
+ await assert.rejects(
+ refreshDesktopList(scope, () => true, ipc, f.relay),
+ /scope changed/,
+ );
+ assert.ok(!f.calls.includes("acknowledge_desktop_profile"));
+ assert.ok(!f.calls.includes(first));
+ }
+});
+
+test("denied coordinate read is not absence, invalid reader result is not an empty list", async () => {
+ const f = fixture();
+ const fetch = f.relay.fetchEvents;
+ f.relay.fetchEvents = async (filter) => {
+ if (filter["#d"]) throw Error("denied");
+ return fetch(filter);
+ };
+ assert.ok(
+ (await refreshDesktopList(scope, () => true, f.ipc, f.relay)).warning,
+ );
+ assert.ok(!f.calls.includes(first));
+ await assert.rejects(
+ refreshDesktopList(
+ scope,
+ () => true,
+ async () => {
+ throw Error("invalid");
+ },
+ f.relay,
+ ),
+ );
+});
+
+test("rendered list distinguishes current, partial, unavailable and empty without online claims", () => {
+ const list = {
+ rows: [{ id: "a", name: "Desktop a", updated: 100 }],
+ local: "a",
+ warning: "",
+ partial: true,
+ };
+ const render = (data, error = false) =>
+ renderToStaticMarkup(
+ React.createElement(DesktopListView, {
+ list: data,
+ error,
+ loading: false,
+ refresh() {},
+ }),
+ );
+ const html = render(list, true);
+ assert.match(html, /This Desktop/);
+ assert.match(html, /Profile updated/);
+ assert.match(html, /Partial list/);
+ assert.match(html, /unavailable/);
+ assert.match(html, /Desktop a/);
+ assert.doesNotMatch(html, /No Desktop profiles found/);
+ assert.match(
+ render({ ...list, rows: [], partial: false }),
+ /No Desktop profiles found/,
+ );
+ assert.doesNotMatch(html, /Last heard|Online|Offline/);
+});
+
+test("mounted cache clears both scopes, fences late reads and retains rows on failure", async () => {
+ const { JSDOM } = await import("jsdom");
+ const dom = new JSDOM("
", {
+ url: "https://desktop.test",
+ });
+ Object.assign(globalThis, {
+ window: dom.window,
+ document: dom.window.document,
+ localStorage: dom.window.localStorage,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ });
+ const { createRoot } = await import("react-dom/client");
+ const { QueryClient, QueryClientProvider } = await import(
+ "@tanstack/react-query"
+ );
+ const { CommunitiesProvider, useCommunities } = await import(
+ "../communities/useCommunities.tsx"
+ );
+ const { KnownDesktops, DesktopListStartup } = await import(
+ "./ui/KnownDesktops.tsx"
+ );
+ const { relayClient } = await import("../../shared/api/relayClient.ts");
+ const communities = ["a", "b"].map((id) => ({
+ id,
+ name: id,
+ relayUrl: `wss://${id}.example`,
+ }));
+ localStorage.setItem("buzz-communities", JSON.stringify(communities));
+ localStorage.setItem("buzz-active-community-id", "a");
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ client.setQueryData(["identity"], { pubkey: "owner-a" });
+ let controls;
+ let fail = false;
+ let hold = false;
+ let release;
+ let current;
+ const originalFetch = relayClient.fetchEvents;
+ const originalPublish = relayClient.publishEvent;
+ window.__TAURI_INTERNALS__ = {
+ invoke: async (command, args) => {
+ if (command === "prepare_desktop_profile") {
+ current = {
+ ...first,
+ id: `${args.owner}-${args.community}`,
+ tags: [["d", args.owner]],
+ };
+ return { event: current };
+ }
+ assert.equal(command, "read_desktop_profiles");
+ return args.events.map((event) => ({
+ id: event.id,
+ name: event.id,
+ updated: 100,
+ }));
+ },
+ };
+ relayClient.fetchEvents = async (filter) => {
+ if (fail) throw Error("unavailable");
+ if (filter["#d"]) return [current];
+ const rows = [current];
+ if (hold) {
+ hold = false;
+ return new Promise((resolve) => {
+ release = () => resolve(rows);
+ });
+ }
+ return rows;
+ };
+ relayClient.publishEvent = async () => {
+ throw Error("unexpected rewrite");
+ };
+ function Screen() {
+ controls = useCommunities();
+ return React.createElement(
+ React.Fragment,
+ null,
+ React.createElement(DesktopListStartup),
+ React.createElement(KnownDesktops),
+ );
+ }
+ const root = createRoot(document.getElementById("root"));
+ const settle = () =>
+ React.act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ });
+ const text = () => document.body.textContent;
+ try {
+ await React.act(async () =>
+ root.render(
+ React.createElement(
+ QueryClientProvider,
+ { client },
+ React.createElement(
+ CommunitiesProvider,
+ null,
+ React.createElement(Screen),
+ ),
+ ),
+ ),
+ );
+ await settle();
+ assert.match(text(), /owner-a-wss:\/\/a.example/);
+ hold = true;
+ await React.act(async () => {
+ void client.refetchQueries({ queryKey: ["desktop-profiles"] });
+ });
+ assert.equal(typeof release, "function");
+ await React.act(async () => {
+ client.setQueryData(["identity"], { pubkey: "owner-b" });
+ });
+ await settle();
+ assert.doesNotMatch(text(), /owner-a/);
+ await React.act(async () => release());
+ await settle();
+ assert.match(text(), /owner-b-wss:\/\/a.example/);
+ assert.doesNotMatch(text(), /owner-a/);
+ assert.equal(
+ client.getQueryData(["desktop-profiles", "owner-a", "wss://a.example"]),
+ undefined,
+ );
+ fail = true;
+ await React.act(async () => {
+ await client.refetchQueries({ queryKey: ["desktop-profiles"] });
+ });
+ await settle();
+ assert.match(text(), /unavailable/);
+ assert.match(text(), /owner-b-wss:\/\/a.example/);
+ await React.act(async () => controls.switchCommunity("b"));
+ await settle();
+ assert.doesNotMatch(text(), /owner-b-wss:\/\/a.example/);
+ fail = false;
+ await React.act(async () => {
+ await client.refetchQueries({ queryKey: ["desktop-profiles"] });
+ });
+ await settle();
+ assert.match(text(), /owner-b-wss:\/\/b.example/);
+ } finally {
+ await React.act(async () => root.unmount());
+ client.clear();
+ relayClient.fetchEvents = originalFetch;
+ relayClient.publishEvent = originalPublish;
+ dom.window.close();
+ }
+});
diff --git a/desktop/src/features/agents/desktopList.ts b/desktop/src/features/agents/desktopList.ts
new file mode 100644
index 00000000000..da3f0cea6d7
--- /dev/null
+++ b/desktop/src/features/agents/desktopList.ts
@@ -0,0 +1,69 @@
+import { invoke } from "@tauri-apps/api/core";
+import { relayClient } from "@/shared/api/relayClient";
+import type { RelayEvent } from "@/shared/api/types";
+
+export type DesktopRow = { id: string; name: string; updated: number };
+export type DesktopScope = { owner: string; community: string };
+export type DesktopList = {
+ rows: DesktopRow[];
+ local: string;
+ partial: boolean;
+ warning: string;
+};
+const KIND = 30180;
+const LIMIT = 100;
+
+/** Every continuation belongs to this mounted owner/community, including late ACKs. */
+export async function refreshDesktopList(
+ scope: DesktopScope,
+ active: () => boolean,
+ ipc = invoke,
+ relay = relayClient,
+): Promise {
+ const epoch = relay.getSessionEpoch();
+ const check = () => {
+ if (!active() || epoch !== relay.getSessionEpoch())
+ throw new Error("Desktop scope changed");
+ };
+ const wait = async (work: Promise) => {
+ const result = await work;
+ check();
+ return result;
+ };
+ const read = (events: RelayEvent[]) =>
+ wait(ipc("read_desktop_profiles", { ...scope, events }));
+ const filter = { kinds: [KIND], authors: [scope.owner] };
+ check();
+ let local = "";
+ let warning = "";
+ try {
+ const { event } = await wait(
+ ipc<{ event: RelayEvent }>("prepare_desktop_profile", scope),
+ );
+ const [profile] = await read([event]);
+ local = profile.id;
+ // A bounded inventory is never evidence that this coordinate is missing.
+ const head = await wait(
+ relay.fetchEvents({ ...filter, "#d": [local], limit: 1 }),
+ );
+ await read(head);
+ if (head.length && head[0].id !== event.id)
+ throw new Error("Desktop profile differs on relay");
+ if (!head.length)
+ await wait(
+ relay.publishEvent(
+ event,
+ "Desktop publish timed out",
+ "Desktop publish failed",
+ ),
+ );
+ } catch {
+ check();
+ warning =
+ "This Desktop profile could not be synchronized. Retry to publish it.";
+ }
+ // Listing is independent of local publication and never uses scalar presence.
+ const events = await wait(relay.fetchEvents({ ...filter, limit: LIMIT }));
+ const rows = await read(events);
+ return { rows, local, partial: events.length === LIMIT, warning };
+}
diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx
index a31fec44c49..1212a8b8826 100644
--- a/desktop/src/features/agents/ui/AgentsView.tsx
+++ b/desktop/src/features/agents/ui/AgentsView.tsx
@@ -1,3 +1,4 @@
+import { KnownDesktops } from "./KnownDesktops";
import * as React from "react";
import { EllipsisVertical, OctagonX, Settings2 } from "lucide-react";
import {
@@ -217,6 +218,7 @@ export function AgentsView() {
description="Set up and manage your agents."
title="Agents"
/>
+
void;
+};
+
+function useDesktopList() {
+ const owner = useIdentityQuery().data?.pubkey;
+ const { activeCommunity } = useCommunities();
+ const community = activeCommunity?.relayUrl
+ .trim()
+ .replace(/^http/, "ws")
+ .replace(/\/+$/, "");
+ return useQuery({
+ queryKey: ["desktop-profiles", owner, community],
+ enabled: !!owner && !!community,
+ queryFn: ({ signal }) => {
+ if (!owner || !community) throw new Error("Desktop scope unavailable");
+ return refreshDesktopList({ owner, community }, () => !signal.aborted);
+ },
+ // Last observer removal cancels and evicts decrypted data, including on an
+ // account switch within the same community. No previous-key placeholder.
+ gcTime: 0,
+ retry: false,
+ refetchOnWindowFocus: false,
+ });
+}
+
+/** Startup and Agents share the existing owner/community query cache. */
+export function DesktopListStartup() {
+ const { refetch } = useDesktopList();
+ useEffect(
+ () =>
+ relayClient.subscribeToReconnects(() => {
+ void refetch();
+ }),
+ [refetch],
+ );
+ return null;
+}
+
+export function KnownDesktops() {
+ const query = useDesktopList();
+ return (
+ {
+ void query.refetch();
+ }}
+ />
+ );
+}
+
+export function DesktopListView({ list, loading, error, refresh }: View) {
+ return (
+
+
+
Known Desktops
+
+ Refresh
+
+
+
+ Private to you. Saved profiles do not indicate whether a Desktop is
+ online or ready to run agents.
+
+ {loading && Loading Desktop profiles…
}
+ {error && (
+
+ Desktop profiles unavailable. Previously loaded profiles are retained.
+
+ )}
+ {list?.warning && {list.warning}
}
+ {list?.partial && (
+ Partial list: showing up to 100 profiles.
+ )}
+ {list && !list.rows.length && !error && No Desktop profiles found.
}
+
+
+ );
+}
diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts
index 95bcec79700..93384a40741 100644
--- a/desktop/src/shared/api/relayClientSession.ts
+++ b/desktop/src/shared/api/relayClientSession.ts
@@ -111,6 +111,10 @@ export class RelayClient {
setVisibleChannelId(id: string | null) {
this.visibleChannelId = id;
}
+ /** Scope epoch changes before a community or identity transport is replaced. */
+ getSessionEpoch() {
+ return this.sessionEpoch;
+ }
disconnect() {
const error = new Error("Relay disconnected for community switch.");
diff --git a/migrations/0045_desktop_profile_fts.sql b/migrations/0045_desktop_profile_fts.sql
new file mode 100644
index 00000000000..31599368771
--- /dev/null
+++ b/migrations/0045_desktop_profile_fts.sql
@@ -0,0 +1,26 @@
+-- Owner-private Desktop profiles must not enter legacy ciphertext search indexes.
+-- Like 0033, this rewrites events under ACCESS EXCLUSIVE; schedule accordingly.
+DO $$
+DECLARE
+ existing_expression TEXT;
+BEGIN
+ SELECT pg_get_expr(d.adbin, d.adrelid)
+ INTO existing_expression
+ FROM pg_attrdef d
+ JOIN pg_attribute a
+ ON a.attrelid = d.adrelid
+ AND a.attnum = d.adnum
+ WHERE d.adrelid = 'events'::regclass
+ AND a.attname = 'search_tsv';
+
+ IF existing_expression IS NULL THEN
+ RAISE EXCEPTION 'events.search_tsv generated expression not found';
+ END IF;
+
+ ALTER TABLE events DROP COLUMN search_tsv;
+ EXECUTE format(
+ 'ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS (CASE WHEN kind = 30180 THEN NULL::tsvector ELSE (%s) END) STORED',
+ existing_expression
+ );
+ CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv);
+END $$;
diff --git a/schema/schema.sql b/schema/schema.sql
index 09508125622..59147e4204e 100644
--- a/schema/schema.sql
+++ b/schema/schema.sql
@@ -221,7 +221,7 @@ CREATE TABLE events (
-- never matches `@@`.
-- Keep in sync with migrations (final state: 0001 + 0005 + 0014 + 0033).
search_tsv TSVECTOR GENERATED ALWAYS AS (
- CASE WHEN kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector
+ CASE WHEN kind IN (1059, 30179, 30180, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector
ELSE to_tsvector('simple', content)
END
) STORED,
From ff5b04ff10f6859595bd95e6f6eebe412e4a403e Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 11:26:03 -0400
Subject: [PATCH 02/51] fix(relay): admit delayed immutable Desktop profiles
Signed-off-by: Logan Johnson
---
.../src/api/desktop_profile_postgres_tests.rs | 91 +++++++++++++++++++
crates/buzz-relay/src/handlers/ingest.rs | 49 +++++++++-
2 files changed, 137 insertions(+), 3 deletions(-)
diff --git a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs
index 38c8d8af3b2..922c4bb0226 100644
--- a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs
+++ b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs
@@ -165,3 +165,94 @@ async fn desktop_profile_authenticated_owner_query_and_private_storage() {
}
}
}
+
+#[tokio::test]
+#[ignore = "requires Postgres"]
+async fn aged_desktop_profile_retries_through_production_ingest_without_resigning() {
+ let mut state = bridge_handler_test_state()
+ .await
+ .expect("test infrastructure");
+ Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).require_auth_token = true;
+ let host = format!("desktop-retry-{}.example", uuid::Uuid::new_v4().simple());
+ state.db.ensure_configured_community(&host).await.unwrap();
+ let owner = Keys::generate();
+ let outsider = Keys::generate();
+ let profile = buzz_core::desktop_profile::DesktopProfile::new(
+ format!("wss://{host}"),
+ uuid::Uuid::new_v4().simple().to_string(),
+ )
+ .unwrap();
+ let prepared = profile.sign(&owner).unwrap();
+ // Model bytes committed during yesterday's offline first launch. Neither
+ // the first submission nor its duplicate is re-dated or re-signed below.
+ let now = Timestamp::now().as_secs();
+ let aged = EventBuilder::new(prepared.kind, &prepared.content)
+ .tags(prepared.tags.iter().cloned())
+ .custom_created_at(Timestamp::from(now - 86_400))
+ .sign_with_keys(&owner)
+ .unwrap();
+ let raw = json!(aged);
+ for _ in 0..2 {
+ let (status, result) = post(&state, &host, "/events", &owner, raw.clone(), true).await;
+ assert_eq!(status, StatusCode::OK, "{result}");
+ assert_eq!(result["accepted"], true, "{result}");
+ let (status, rows) = post(
+ &state,
+ &host,
+ "/query",
+ &owner,
+ json!([{"kinds":[KIND_DESKTOP_PROFILE], "authors":[owner.public_key().to_hex()], "ids":[aged.id.to_hex()]}]),
+ true,
+ )
+ .await;
+ assert_eq!(status, StatusCode::OK, "{rows}");
+ assert_eq!(rows.as_array().unwrap().len(), 1);
+ for field in [
+ "id",
+ "pubkey",
+ "kind",
+ "created_at",
+ "tags",
+ "content",
+ "sig",
+ ] {
+ assert_eq!(rows[0][field], raw[field], "stored {field} changed");
+ }
+ let stored: nostr::Event = serde_json::from_value(rows[0].clone()).unwrap();
+ assert_eq!(
+ buzz_core::desktop_profile::DesktopProfile::read(
+ &stored,
+ &owner,
+ &format!("wss://{host}")
+ )
+ .unwrap(),
+ profile
+ );
+ }
+ // The age exception grants no signer authority and bypasses no envelope or
+ // signature checks. These calls use the real HTTP -> shared ingest path.
+ let (status, result) = post(&state, &host, "/events", &outsider, raw.clone(), true).await;
+ assert_eq!(status, StatusCode::FORBIDDEN, "{result}");
+ let mut corrupt = raw.clone();
+ corrupt["content"] = json!(format!("{}x", aged.content));
+ let (status, result) = post(&state, &host, "/events", &owner, corrupt, true).await;
+ assert_eq!(status, StatusCode::BAD_REQUEST, "{result}");
+ let invalid = EventBuilder::new(aged.kind, &aged.content)
+ .tag(Tag::identifier("invalid-coordinate"))
+ .custom_created_at(aged.created_at)
+ .sign_with_keys(&owner)
+ .unwrap();
+ let future = EventBuilder::new(aged.kind, &aged.content)
+ .tags(aged.tags.iter().cloned())
+ .custom_created_at(Timestamp::from(now + 86_400))
+ .sign_with_keys(&owner)
+ .unwrap();
+ let ordinary = EventBuilder::text_note("old ordinary event")
+ .custom_created_at(aged.created_at)
+ .sign_with_keys(&owner)
+ .unwrap();
+ for rejected in [invalid, future, ordinary] {
+ let (status, result) = post(&state, &host, "/events", &owner, json!(rejected), true).await;
+ assert_eq!(status, StatusCode::BAD_REQUEST, "{result}");
+ }
+}
diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs
index 613544df9d2..13c781613fb 100644
--- a/crates/buzz-relay/src/handlers/ingest.rs
+++ b/crates/buzz-relay/src/handlers/ingest.rs
@@ -2169,6 +2169,17 @@ pub async fn ingest_event(
result
}
+// Profiles are durable display records, not freshness signals. A Desktop may
+// first publish its immutable signed record long after an offline startup.
+// Only their past-age bound is waived; future drift and all other admission
+// checks still apply. Observation/presence kinds must retain their own window.
+fn timestamp_within_ingest_window(kind: u32, event_ts: u64, now: u64) -> bool {
+ const MAX_TIMESTAMP_DRIFT_SECS: u64 = 900;
+ event_ts <= now.saturating_add(MAX_TIMESTAMP_DRIFT_SECS)
+ && (kind == KIND_DESKTOP_PROFILE
+ || now.saturating_sub(event_ts) <= MAX_TIMESTAMP_DRIFT_SECS)
+}
+
async fn ingest_event_inner(
state: &Arc,
tracer: &Arc,
@@ -2233,10 +2244,8 @@ async fn ingest_event_inner(
}
let event = std::sync::Arc::try_unwrap(event).unwrap_or_else(|arc| (*arc).clone());
- const MAX_TIMESTAMP_DRIFT_SECS: i64 = 900; // ±15 minutes
let now = chrono::Utc::now().timestamp();
- let event_ts = event.created_at.as_secs() as i64;
- if (event_ts - now).abs() > MAX_TIMESTAMP_DRIFT_SECS {
+ if !timestamp_within_ingest_window(kind_u32, event.created_at.as_secs(), now as u64) {
return Err(IngestError::Rejected(
"invalid: event timestamp too far from server time".into(),
));
@@ -3317,6 +3326,40 @@ mod postgres_tests {
));
}
+ #[test]
+ fn immutable_profile_age_exception_is_past_only_and_kind_specific() {
+ let now = 1_800_000_000;
+ // Include the next observation kind explicitly: freshness is not profile age.
+ for kind in [
+ KIND_DESKTOP_PROFILE,
+ 30181,
+ KIND_PROFILE,
+ KIND_EVENT_REMINDER,
+ 1,
+ ] {
+ for (timestamp, ordinary, profile) in [
+ (0, false, true),
+ (now - 86_400, false, true),
+ (now - 901, false, true),
+ (now - 900, true, true),
+ (now, true, true),
+ (now + 900, true, true),
+ (now + 901, false, false),
+ (u64::MAX, false, false),
+ ] {
+ assert_eq!(
+ timestamp_within_ingest_window(kind, timestamp, now),
+ if kind == KIND_DESKTOP_PROFILE {
+ profile
+ } else {
+ ordinary
+ },
+ "kind={kind} timestamp={timestamp}"
+ );
+ }
+ }
+ }
+
#[test]
fn huddle_backing_channel_lookup_outage_is_internal() {
let error = sqlx::Error::Io(std::io::Error::other("database unavailable"));
From 271fefd62900b7024fd1a7050aaa7d7d4f4e6cd6 Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 11:43:12 -0400
Subject: [PATCH 03/51] test(desktop): match unsigned relay ingest drift
constant
Signed-off-by: Logan Johnson
---
desktop/src/shared/api/relayReconnectReplay.test.mjs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/desktop/src/shared/api/relayReconnectReplay.test.mjs b/desktop/src/shared/api/relayReconnectReplay.test.mjs
index 7c7d96f44c1..117731df914 100644
--- a/desktop/src/shared/api/relayReconnectReplay.test.mjs
+++ b/desktop/src/shared/api/relayReconnectReplay.test.mjs
@@ -112,7 +112,7 @@ test("channel replay lookback stays coupled to relay and DB source constants", a
assert.match(
ingest,
new RegExp(
- `MAX_TIMESTAMP_DRIFT_SECS: i64 = ${RELAY_INGEST_FUTURE_TOLERANCE_SECS}`,
+ `MAX_TIMESTAMP_DRIFT_SECS: u64 = ${RELAY_INGEST_FUTURE_TOLERANCE_SECS}`,
),
);
assert.match(
From e5812f053b951fdccd187d21236dd85538e8cd03 Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 10:51:04 -0400
Subject: [PATCH 04/51] feat(desktop): show private per-Desktop last-heard
observations
Signed-off-by: Logan Johnson
---
crates/buzz-core/src/desktop_observation.rs | 123 ++++++++++++++++++
crates/buzz-core/src/desktop_profile.rs | 9 +-
crates/buzz-core/src/kind.rs | 5 +
crates/buzz-core/src/lib.rs | 1 +
crates/buzz-db/src/runtime/migration.rs | 30 ++++-
.../src/api/desktop_profile_postgres_tests.rs | 28 +++-
crates/buzz-relay/src/handlers/event.rs | 5 +
crates/buzz-relay/src/handlers/ingest.rs | 10 +-
.../src/commands/desktop_profiles.rs | 56 +++++++-
desktop/src-tauri/src/lib.rs | 2 +
.../src/features/agents/desktopList.test.mjs | 50 ++++++-
.../agents/desktopObservations.test.mjs | 103 +++++++++++++++
.../features/agents/desktopObservations.ts | 82 ++++++++++++
.../src/features/agents/ui/KnownDesktops.tsx | 77 +++++++++--
migrations/0046_desktop_observation_fts.sql | 26 ++++
schema/schema.sql | 2 +-
16 files changed, 580 insertions(+), 29 deletions(-)
create mode 100644 crates/buzz-core/src/desktop_observation.rs
create mode 100644 desktop/src/features/agents/desktopObservations.test.mjs
create mode 100644 desktop/src/features/agents/desktopObservations.ts
create mode 100644 migrations/0046_desktop_observation_fts.sql
diff --git a/crates/buzz-core/src/desktop_observation.rs b/crates/buzz-core/src/desktop_observation.rs
new file mode 100644
index 00000000000..21ba206280a
--- /dev/null
+++ b/crates/buzz-core/src/desktop_observation.rs
@@ -0,0 +1,123 @@
+//! Owner-private, advisory Desktop observations; never agent readiness or placement.
+use nostr::{nips::nip44, Event, EventBuilder, Keys, Kind, Tag};
+use serde::{Deserialize, Serialize};
+
+use crate::{desktop_profile::DesktopProfile, kind::KIND_DESKTOP_OBSERVATION};
+
+/// A pulse for one local profile. The signed event timestamp is the observed time.
+#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct DesktopObservation {
+ /// Format version.
+ pub v: u8,
+ /// Canonical community, encrypted along with the coordinate.
+ pub community: String,
+ /// Stable Desktop profile coordinate, not an execution credential.
+ pub id: String,
+}
+
+/// Validate the bounded public envelope without decrypting it.
+pub fn validate_envelope(event: &Event) -> Result<(), &'static str> {
+ crate::desktop_profile::validate_private_desktop_envelope(event, KIND_DESKTOP_OBSERVATION)
+}
+
+impl DesktopObservation {
+ /// Observe a profile belonging to this local Desktop.
+ pub fn new(profile: DesktopProfile) -> Self {
+ Self {
+ v: 1,
+ community: profile.community,
+ id: profile.id,
+ }
+ }
+
+ /// Encrypt and sign a fresh observation, without rewriting the durable profile.
+ pub fn sign(&self, keys: &Keys) -> Result {
+ let content = nip44::encrypt(
+ keys.secret_key(),
+ &keys.public_key(),
+ serde_json::to_string(self).map_err(|e| e.to_string())?,
+ nip44::Version::V2,
+ )
+ .map_err(|e| e.to_string())?;
+ EventBuilder::new(Kind::Custom(KIND_DESKTOP_OBSERVATION as u16), content)
+ .tag(Tag::identifier(&self.id))
+ .sign_with_keys(keys)
+ .map_err(|e| e.to_string())
+ }
+
+ /// Authenticate and decrypt an observation before displaying its timestamp.
+ pub fn read(event: &Event, keys: &Keys, community: &str) -> Result {
+ validate_envelope(event)?;
+ event
+ .verify()
+ .map_err(|_| "invalid Desktop observation signature")?;
+ if event.pubkey != keys.public_key() {
+ return Err("foreign Desktop observation".into());
+ }
+ let plaintext = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content)
+ .map_err(|_| "Desktop observation decryption failed")?;
+ let observation: Self =
+ serde_json::from_str(&plaintext).map_err(|_| "invalid Desktop observation")?;
+ let expected = Self::new(DesktopProfile::new(
+ community.to_owned(),
+ event.tags.identifier().unwrap_or_default().to_owned(),
+ )?);
+ if observation != expected {
+ return Err("Desktop observation scope mismatch".into());
+ }
+ Ok(observation)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn observation_is_private_scoped_and_distinct_from_profile() {
+ let keys = Keys::generate();
+ let profile = DesktopProfile::new("wss://one.example".into(), "a".repeat(32)).unwrap();
+ let saved = profile.sign(&keys).unwrap();
+ let observation = DesktopObservation::new(profile);
+ let event = observation.sign(&keys).unwrap();
+ assert_eq!(
+ DesktopObservation::read(&event, &keys, &observation.community).unwrap(),
+ observation
+ );
+ assert!(DesktopObservation::read(&event, &keys, "wss://two.example").is_err());
+ assert!(
+ DesktopObservation::read(&event, &Keys::generate(), &observation.community).is_err()
+ );
+ assert!(DesktopObservation::read(&saved, &keys, &observation.community).is_err());
+ assert!(DesktopProfile::read(&event, &keys, &observation.community).is_err());
+ let forged_author = EventBuilder::new(event.kind, &event.content)
+ .tags(event.tags.clone())
+ .sign_with_keys(&Keys::generate())
+ .unwrap();
+ assert!(DesktopObservation::read(&forged_author, &keys, &observation.community).is_err());
+ let mut tampered = event.clone();
+ tampered.created_at = nostr::Timestamp::from(1);
+ assert!(DesktopObservation::read(&tampered, &keys, &observation.community).is_err());
+ assert!(crate::kind::AUTHOR_ONLY_KINDS.contains(&KIND_DESKTOP_OBSERVATION));
+ for field in ["v", "community", "id", "extra"] {
+ let mut payload = serde_json::to_value(&observation).unwrap();
+ payload[field] = serde_json::json!("invalid");
+ let ciphertext = nip44::encrypt(
+ keys.secret_key(),
+ &keys.public_key(),
+ payload.to_string(),
+ nip44::Version::V2,
+ )
+ .unwrap();
+ let invalid = EventBuilder::new(event.kind, ciphertext)
+ .tags(event.tags.clone())
+ .sign_with_keys(&keys)
+ .unwrap();
+ assert!(
+ DesktopObservation::read(&invalid, &keys, &observation.community).is_err(),
+ "{field}"
+ );
+ }
+ }
+}
diff --git a/crates/buzz-core/src/desktop_profile.rs b/crates/buzz-core/src/desktop_profile.rs
index 697808c4677..5b08a438b3a 100644
--- a/crates/buzz-core/src/desktop_profile.rs
+++ b/crates/buzz-core/src/desktop_profile.rs
@@ -20,8 +20,15 @@ pub struct DesktopProfile {
/// Validate the public envelope without decrypting private content.
pub fn validate_envelope(event: &Event) -> Result<(), &'static str> {
+ validate_private_desktop_envelope(event, KIND_DESKTOP_PROFILE)
+}
+
+pub(crate) fn validate_private_desktop_envelope(
+ event: &Event,
+ kind: u32,
+) -> Result<(), &'static str> {
let tags: Vec<_> = event.tags.iter().map(|tag| tag.as_slice()).collect();
- if event.kind.as_u16() as u32 != KIND_DESKTOP_PROFILE
+ if event.kind.as_u16() as u32 != kind
|| event.created_at.as_secs() > 253_402_300_799
|| !(132..=2048).contains(&event.content.len())
|| tags.len() != 1
diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs
index bb8be1a2819..1e5bfd43d6e 100644
--- a/crates/buzz-core/src/kind.rs
+++ b/crates/buzz-core/src/kind.rs
@@ -120,6 +120,9 @@ pub const KIND_PRIVATE_MANAGED_AGENT: u32 = 30179;
/// Owner-private encrypted Desktop identity/name, keyed by installation coordinate.
pub const KIND_DESKTOP_PROFILE: u32 = 30180;
+/// Owner-private, per-Desktop last-heard observation; not online or readiness.
+pub const KIND_DESKTOP_OBSERVATION: u32 = 30181;
+
/// Kinds whose stored events are readable only by their author.
///
/// The relay must never reveal the existence, count, tags, content, schedule,
@@ -134,6 +137,7 @@ pub const AUTHOR_ONLY_KINDS: &[u32] = &[
KIND_PUSH_LEASE,
KIND_PRIVATE_MANAGED_AGENT,
KIND_DESKTOP_PROFILE,
+ KIND_DESKTOP_OBSERVATION,
];
/// Kinds that require a result-level read gate beyond the filter-layer
@@ -664,6 +668,7 @@ pub const ALL_KINDS: &[u32] = &[
KIND_TEAM_CATALOG,
KIND_PRIVATE_MANAGED_AGENT,
KIND_DESKTOP_PROFILE,
+ KIND_DESKTOP_OBSERVATION,
KIND_REPORT,
KIND_PRODUCT_FEEDBACK,
KIND_NIP29_PUT_USER,
diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs
index fe94c0f356a..2105e4a15a7 100644
--- a/crates/buzz-core/src/lib.rs
+++ b/crates/buzz-core/src/lib.rs
@@ -9,6 +9,7 @@
pub mod agent_turn_metric;
/// Channel and membership enums shared across crates.
pub mod channel;
+pub mod desktop_observation;
/// Owner-private Desktop display profiles.
pub mod desktop_profile;
/// NIP-AE Agent Engrams — slug grammar, conversation key, d-tag derivation,
diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs
index 8cc2b3cd08d..5a0d2a64150 100644
--- a/crates/buzz-db/src/runtime/migration.rs
+++ b/crates/buzz-db/src/runtime/migration.rs
@@ -702,7 +702,7 @@ mod postgres_tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);
- assert_eq!(migrations.len(), 45);
+ assert_eq!(migrations.len(), 46);
assert_eq!(migrations[0].version, 1);
assert_eq!(&*migrations[0].description, "initial schema");
assert!(migrations[0]
@@ -910,8 +910,9 @@ mod postgres_tests {
assert!(migrations[32].sql.as_str().contains("kind = 30179"));
assert!(migrations[32].sql.as_str().contains("search_tsv"));
assert!(!migrations[0].sql.as_str().contains("30179"));
- assert!(include_str!("../../../../schema/schema.sql")
- .contains("kind IN (1059, 30179, 30180, 30300, 30350, 30622, 44100, 44101, 44200)"));
+ assert!(include_str!("../../../../schema/schema.sql").contains(
+ "kind IN (1059, 30179, 30180, 30181, 30300, 30350, 30622, 44100, 44101, 44200)"
+ ));
// Public push-gateway authority is intentionally deployment-global and
// durable: immediate revocation and hostile-relay admission cannot be
@@ -2391,6 +2392,7 @@ mod postgres_tests {
(2_u8, 30_350_i32),
(3_u8, 30_179_i32),
(4_u8, 30_180_i32),
+ (5_u8, 30_181_i32),
] {
sqlx::query(
"INSERT INTO events \
@@ -2420,7 +2422,13 @@ mod postgres_tests {
.expect("read pre-push search behavior");
assert_eq!(
before,
- vec![(1, true), (30_179, true), (30_180, true), (30_350, true)]
+ vec![
+ (1, true),
+ (30_179, true),
+ (30_180, true),
+ (30_181, true),
+ (30_350, true)
+ ]
);
// 0014 fixes 30350 only. A brownfield database that stopped here still
@@ -2442,6 +2450,7 @@ mod postgres_tests {
(1, Some(true)),
(30_179, Some(true)),
(30_180, Some(true)),
+ (30_181, Some(true)),
(30_350, None)
]
);
@@ -2457,6 +2466,18 @@ mod postgres_tests {
.await
.expect("read pre-0045 Desktop search behavior");
assert!(desktop_indexed, "upgrade fixture must exercise legacy FTS");
+ run_migrations_through(&pool, 45)
+ .await
+ .expect("apply through 45");
+ let observation_indexed: bool =
+ sqlx::query_scalar("SELECT search_tsv IS NOT NULL FROM events WHERE kind = 30181")
+ .fetch_one(&pool)
+ .await
+ .unwrap();
+ assert!(
+ observation_indexed,
+ "0046 must change brownfield observation FTS"
+ );
run_migrations(&pool)
.await
@@ -2474,6 +2495,7 @@ mod postgres_tests {
(1, Some(true)),
(30_179, None),
(30_180, None),
+ (30_181, None),
(30_350, None)
]
);
diff --git a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs
index 922c4bb0226..5d863666832 100644
--- a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs
+++ b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs
@@ -2,7 +2,7 @@
use super::postgres_tests::bridge_handler_test_state;
use super::*;
use axum::{body::Body, http::Request};
-use buzz_core::kind::KIND_DESKTOP_PROFILE;
+use buzz_core::kind::{KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE};
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
use serde_json::json;
use tower::ServiceExt;
@@ -67,6 +67,16 @@ fn drain(rx: &mut tokio::sync::mpsc::Receiver) -> Ve
#[tokio::test]
#[ignore = "requires Postgres"]
async fn desktop_profile_authenticated_owner_query_and_private_storage() {
+ assert_private_desktop(KIND_DESKTOP_PROFILE).await;
+}
+
+#[tokio::test]
+#[ignore = "requires Postgres"]
+async fn desktop_observation_authenticated_owner_query_and_private_storage() {
+ assert_private_desktop(KIND_DESKTOP_OBSERVATION).await;
+}
+
+async fn assert_private_desktop(kind: u32) {
let mut state = bridge_handler_test_state()
.await
.expect("test infrastructure");
@@ -86,13 +96,21 @@ async fn desktop_profile_authenticated_owner_query_and_private_storage() {
uuid::Uuid::new_v4().simple().to_string(),
)
.unwrap();
- let event = profile.sign(&owner).unwrap();
+ let id = profile.id.clone();
+ let event = if kind == KIND_DESKTOP_PROFILE {
+ profile.sign(&owner).unwrap()
+ } else {
+ buzz_core::desktop_observation::DesktopObservation::new(profile)
+ .sign(&owner)
+ .unwrap()
+ };
let (status, result) = post(&state, &host, "/events", &owner, json!(event), true).await;
assert_eq!(status, StatusCode::OK, "{result}");
assert_eq!(result["accepted"], true, "{result}");
// Match Desktop's actual bounded owner+kind inventory and exact-coordinate probe.
- let own = json!([{"kinds":[KIND_DESKTOP_PROFILE], "authors":[owner.public_key().to_hex()], "limit":100}]);
- let exact = json!([{"kinds":[KIND_DESKTOP_PROFILE], "authors":[owner.public_key().to_hex()], "#d":[profile.id], "limit":1}]);
+ let own = json!([{"kinds":[kind], "authors":[owner.public_key().to_hex()], "limit":100}]);
+ let exact =
+ json!([{"kinds":[kind], "authors":[owner.public_key().to_hex()], "#d":[id], "limit":1}]);
for filters in [&own, &exact] {
let (status, rows) = post(&state, &host, "/query", &owner, filters.clone(), true).await;
assert_eq!(status, StatusCode::OK, "{rows}");
@@ -105,7 +123,7 @@ async fn desktop_profile_authenticated_owner_query_and_private_storage() {
assert_eq!(status, StatusCode::UNAUTHORIZED, "{result}");
}
// Known IDs cannot grant an authenticated outsider read access either.
- let known = json!([{"ids":[event.id.to_hex()], "kinds":[KIND_DESKTOP_PROFILE,1]}]);
+ let known = json!([{"ids":[event.id.to_hex()], "kinds":[kind,1]}]);
let (status, rows) = post(&state, &host, "/query", &outsider, known, true).await;
assert_eq!(status, StatusCode::OK, "{rows}");
assert_eq!(rows, json!([]));
diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs
index 5ed5f1037e2..b2f86ec357c 100644
--- a/crates/buzz-relay/src/handlers/event.rs
+++ b/crates/buzz-relay/src/handlers/event.rs
@@ -2215,6 +2215,11 @@ mod tests {
assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_PROFILE).await;
}
+ #[tokio::test]
+ async fn desktop_observation_delivers_to_author_only() {
+ assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_OBSERVATION).await;
+ }
+
async fn assert_author_only_fanout(kind: u32) {
let state = test_state().await;
diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs
index 13c781613fb..f60a76fd4a8 100644
--- a/crates/buzz-relay/src/handlers/ingest.rs
+++ b/crates/buzz-relay/src/handlers/ingest.rs
@@ -10,7 +10,6 @@ use tracing::{debug, error, info, warn};
use uuid::Uuid;
use buzz_auth::Scope;
-use buzz_core::kind::KIND_DESKTOP_PROFILE;
use buzz_core::kind::{
event_kind_u32, is_identity_archive_request_kind, is_parameterized_replaceable,
is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_AGENT_TURN_METRIC,
@@ -37,6 +36,7 @@ use buzz_core::kind::{
RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER,
RELAY_ADMIN_SET_WORKSPACE_PROFILE,
};
+use buzz_core::kind::{KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE};
use buzz_core::tenant::TenantContext;
use buzz_core::verification::verify_event;
use buzz_core::CommunityId;
@@ -437,7 +437,7 @@ fn map_push_accept_error(error: super::push_lease::AcceptError) -> IngestError {
/// Returns `Err` for unknown kinds — the relay rejects them.
fn required_scope_for_kind(kind: u32, event: &Event) -> Result {
match kind {
- KIND_PROFILE | KIND_DESKTOP_PROFILE => Ok(Scope::UsersWrite),
+ KIND_PROFILE | KIND_DESKTOP_PROFILE | KIND_DESKTOP_OBSERVATION => Ok(Scope::UsersWrite),
KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite),
KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM
| KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT
@@ -659,6 +659,7 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
| KIND_MANAGED_AGENT
| KIND_PRIVATE_MANAGED_AGENT
| KIND_DESKTOP_PROFILE
+ | KIND_DESKTOP_OBSERVATION
| KIND_TEAM_CATALOG
// NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope).
// Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag).
@@ -2792,6 +2793,11 @@ async fn ingest_event_inner(
}
}
+ if kind_u32 == KIND_DESKTOP_OBSERVATION {
+ buzz_core::desktop_observation::validate_envelope(&event)
+ .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
+ }
+
if kind_u32 == KIND_DESKTOP_PROFILE {
buzz_core::desktop_profile::validate_envelope(&event)
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
diff --git a/desktop/src-tauri/src/commands/desktop_profiles.rs b/desktop/src-tauri/src/commands/desktop_profiles.rs
index 3b3017da71b..d3438d71c51 100644
--- a/desktop/src-tauri/src/commands/desktop_profiles.rs
+++ b/desktop/src-tauri/src/commands/desktop_profiles.rs
@@ -1,5 +1,5 @@
//! Durable read-only Desktop profiles, separate from persona publication queues.
-use buzz_core_pkg::desktop_profile::DesktopProfile;
+use buzz_core_pkg::{desktop_observation::DesktopObservation, desktop_profile::DesktopProfile};
use nostr::{Event, JsonUtil};
use rusqlite::{Connection, OptionalExtension, TransactionBehavior};
use serde_json::{json, Value};
@@ -97,6 +97,50 @@ pub fn read_desktop_profiles(
Ok(json!(rows?))
}
+/// Sign a fresh pulse for the persisted local profile, never an arbitrary host ID.
+#[tauri::command]
+pub fn prepare_desktop_observation(
+ app: AppHandle,
+ state: State<'_, AppState>,
+ owner: String,
+ community: String,
+) -> Result {
+ let scope = scope(&app, &state, &owner, &community)?;
+ Ok(json!({ "event": prepare_observation(&mut open_retention_db(&scope.db_path)?, &scope)? }))
+}
+
+fn prepare_observation(conn: &mut Connection, scope: &RetentionScope) -> Result {
+ let saved = prepare(conn, scope)?;
+ let event: Event = serde_json::from_value(saved["event"].clone()).map_err(|e| e.to_string())?;
+ let profile = DesktopProfile::read(
+ &event,
+ &scope.owner_keys,
+ scope.relay_url.trim_end_matches('/'),
+ )?;
+ DesktopObservation::new(profile).sign(&scope.owner_keys)
+}
+
+/// Verify bounded owner-private observations; the UI treats their timestamps as advisory.
+#[tauri::command]
+pub fn read_desktop_observations(
+ app: AppHandle,
+ state: State<'_, AppState>,
+ owner: String,
+ community: String,
+ events: Vec,
+) -> Result {
+ let scope = scope(&app, &state, &owner, &community)?;
+ if events.len() > 100 {
+ return Err("too many Desktop observations".into());
+ }
+ let mut rows = Vec::with_capacity(events.len());
+ for event in &events {
+ let observation = DesktopObservation::read(event, &scope.owner_keys, &community)?;
+ rows.push(json!({ "id": observation.id, "heard": event.created_at.as_secs() }));
+ }
+ Ok(json!(rows))
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -119,6 +163,16 @@ mod tests {
let accepted = prepare(&mut reopened, &scope).unwrap();
assert_eq!(accepted, first);
assert_eq!(reopened.total_changes(), 0, "no repeated native writes");
+ let pulse = prepare_observation(&mut reopened, &scope).unwrap();
+ let observation =
+ DesktopObservation::read(&pulse, &scope.owner_keys, &scope.relay_url).unwrap();
+ assert_eq!(first["event"]["tags"][0][1], observation.id);
+ assert_eq!(
+ prepare(&mut reopened, &scope).unwrap(),
+ first,
+ "pulses never rewrite profiles"
+ );
+ assert_eq!(reopened.total_changes(), 0);
let other = RetentionScope {
db_path: dir.path().join("two.db"),
relay_url: scope.relay_url.clone(),
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 65fe1aaa4d7..edffed5e462 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -554,6 +554,8 @@ pub fn run() {
get_identity,
prepare_desktop_profile,
read_desktop_profiles,
+ prepare_desktop_observation,
+ read_desktop_observations,
get_nsec,
generate_backup_passphrase,
create_ncryptsec_backup,
diff --git a/desktop/src/features/agents/desktopList.test.mjs b/desktop/src/features/agents/desktopList.test.mjs
index 4ffa141e0d2..6f7330d978c 100644
--- a/desktop/src/features/agents/desktopList.test.mjs
+++ b/desktop/src/features/agents/desktopList.test.mjs
@@ -155,10 +155,11 @@ test("rendered list distinguishes current, partial, unavailable and empty withou
render({ ...list, rows: [], partial: false }),
/No Desktop profiles found/,
);
- assert.doesNotMatch(html, /Last heard|Online|Offline/);
+ assert.match(html, /Last heard: Unknown/);
+ assert.doesNotMatch(html, /Online|Offline/);
});
-test("mounted cache clears both scopes, fences late reads and retains rows on failure", async () => {
+test("mounted cache clears both scopes, fences late reads and retains rows on failure", async (t) => {
const { JSDOM } = await import("jsdom");
const dom = new JSDOM("
", {
url: "https://desktop.test",
@@ -198,8 +199,26 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa
let current;
const originalFetch = relayClient.fetchEvents;
const originalPublish = relayClient.publishEvent;
+ const originalReconnect = relayClient.subscribeToReconnects;
+ let reconnect;
+ let pulses = 0;
+ relayClient.subscribeToReconnects = (callback) => {
+ reconnect = callback;
+ return () => {
+ reconnect = undefined;
+ };
+ };
window.__TAURI_INTERNALS__ = {
invoke: async (command, args) => {
+ if (command === "prepare_desktop_observation")
+ return { event: { ...first, kind: 30181 } };
+ if (command === "read_desktop_observations")
+ return [
+ {
+ id: `${args.owner}-${args.community}`,
+ heard: Math.floor(Date.now() / 1000),
+ },
+ ];
if (command === "prepare_desktop_profile") {
current = {
...first,
@@ -218,6 +237,7 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa
};
relayClient.fetchEvents = async (filter) => {
if (fail) throw Error("unavailable");
+ if (filter.kinds[0] === 30181) return [];
if (filter["#d"]) return [current];
const rows = [current];
if (hold) {
@@ -228,8 +248,9 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa
}
return rows;
};
- relayClient.publishEvent = async () => {
- throw Error("unexpected rewrite");
+ relayClient.publishEvent = async (event) => {
+ assert.equal(event.kind, 30181, "no profile heartbeat rewrite");
+ pulses++;
};
function Screen() {
controls = useCommunities();
@@ -246,6 +267,7 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa
await new Promise((resolve) => setTimeout(resolve, 20));
});
const text = () => document.body.textContent;
+ t.mock.timers.enable({ apis: ["setInterval"] });
try {
await React.act(async () =>
root.render(
@@ -262,6 +284,15 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa
);
await settle();
assert.match(text(), /owner-a-wss:\/\/a.example/);
+ assert.match(text(), /Last heard: Recent/);
+ const beforeReconnect = pulses;
+ await React.act(async () => reconnect());
+ await settle();
+ assert.ok(pulses > beforeReconnect, "reconnect reports a fresh pulse");
+ const beforeTimer = pulses;
+ await React.act(async () => t.mock.timers.tick(60_000));
+ await settle();
+ assert.ok(pulses > beforeTimer, "bounded periodic publisher runs");
hold = true;
await React.act(async () => {
void client.refetchQueries({ queryKey: ["desktop-profiles"] });
@@ -283,8 +314,11 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa
fail = true;
await React.act(async () => {
await client.refetchQueries({ queryKey: ["desktop-profiles"] });
+ await client.refetchQueries({ queryKey: ["desktop-observations"] });
});
await settle();
+ assert.match(text(), /Last-heard refresh unavailable/);
+ assert.match(text(), /Last heard: Recent/);
assert.match(text(), /unavailable/);
assert.match(text(), /owner-b-wss:\/\/a.example/);
await React.act(async () => controls.switchCommunity("b"));
@@ -293,6 +327,7 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa
fail = false;
await React.act(async () => {
await client.refetchQueries({ queryKey: ["desktop-profiles"] });
+ await client.refetchQueries({ queryKey: ["desktop-observations"] });
});
await settle();
assert.match(text(), /owner-b-wss:\/\/b.example/);
@@ -301,6 +336,13 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa
client.clear();
relayClient.fetchEvents = originalFetch;
relayClient.publishEvent = originalPublish;
+ relayClient.subscribeToReconnects = originalReconnect;
+ assert.equal(
+ reconnect,
+ undefined,
+ "reconnect producer unsubscribed on unmount",
+ );
+ t.mock.timers.reset();
dom.window.close();
}
});
diff --git a/desktop/src/features/agents/desktopObservations.test.mjs b/desktop/src/features/agents/desktopObservations.test.mjs
new file mode 100644
index 00000000000..af9418365f9
--- /dev/null
+++ b/desktop/src/features/agents/desktopObservations.test.mjs
@@ -0,0 +1,103 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ refreshDesktopObservations,
+ desktopFreshness,
+} from "./desktopObservations.ts";
+
+const scope = { owner: "owner-a", community: "wss://a.example" };
+function fixture(boundary) {
+ let epoch = 0;
+ const calls = [];
+ const event = { id: "pulse", kind: 30181 };
+ const rows = [
+ { id: "a", heard: 100 },
+ { id: "b", heard: 10 },
+ ];
+ const finish = (name, value) => {
+ if (name === boundary) epoch++;
+ return value;
+ };
+ const f = {
+ calls,
+ rows,
+ ipc: async (command, args) => {
+ assert.equal(args.owner, scope.owner);
+ assert.equal(args.community, scope.community);
+ calls.push(command);
+ if (command === "prepare_desktop_observation")
+ return finish(command, { event });
+ assert.equal(command, "read_desktop_observations");
+ return finish(command, rows);
+ },
+ relay: {
+ getSessionEpoch: () => epoch,
+ publishEvent: async (value) => {
+ assert.equal(value, event);
+ calls.push("publish");
+ finish("publishEvent");
+ },
+ fetchEvents: async (filter) => {
+ assert.deepEqual(filter, {
+ kinds: [30181],
+ authors: [scope.owner],
+ limit: 100,
+ });
+ calls.push("fetch");
+ return finish("fetchEvents", [event]);
+ },
+ },
+ };
+ f.refresh = (active = () => true) =>
+ refreshDesktopObservations(scope, active, f.ipc, f.relay);
+ return f;
+}
+
+test("every async completion is fenced, including decrypt and late ACK", async () => {
+ for (const boundary of [
+ "prepare_desktop_observation",
+ "publishEvent",
+ "fetchEvents",
+ "read_desktop_observations",
+ ]) {
+ const f = fixture(boundary);
+ await assert.rejects(f.refresh(), /scope changed/);
+ if (boundary === "prepare_desktop_observation")
+ assert.ok(!f.calls.includes("publish"));
+ }
+ const f = fixture();
+ await assert.rejects(f.refresh(() => false));
+ assert.deepEqual(f.calls, []);
+});
+
+test("bounded/invalid reads are not silently authoritative, clocks and staleness stay advisory", async () => {
+ const f = fixture();
+ const good = await f.refresh();
+ assert.deepEqual(good.rows, f.rows);
+ assert.deepEqual(f.calls, [
+ "prepare_desktop_observation",
+ "publish",
+ "fetch",
+ "read_desktop_observations",
+ ]);
+ f.relay.publishEvent = async () => {
+ throw Error("offline");
+ };
+ assert.ok((await f.refresh()).warning);
+ f.relay.fetchEvents = async () => Array(100).fill({});
+ assert.equal((await f.refresh()).partial, true);
+ const read = f.ipc;
+ f.ipc = async (command, args) => {
+ if (command === "read_desktop_observations")
+ throw Error("invalid signature");
+ return read(command, args);
+ };
+ await assert.rejects(f.refresh(), /invalid signature/);
+ for (const [heard, now, expected] of [
+ [undefined, 100, "Unknown"],
+ [100, 280, "Recent"],
+ [100, 281, "Stale"],
+ [101, 100, "Unknown (Desktop clock ahead)"],
+ ])
+ assert.ok(desktopFreshness(heard, now).startsWith(expected));
+});
diff --git a/desktop/src/features/agents/desktopObservations.ts b/desktop/src/features/agents/desktopObservations.ts
new file mode 100644
index 00000000000..5de30fe8139
--- /dev/null
+++ b/desktop/src/features/agents/desktopObservations.ts
@@ -0,0 +1,82 @@
+import { invoke } from "@tauri-apps/api/core";
+import { useQuery } from "@tanstack/react-query";
+import { relayClient } from "@/shared/api/relayClient";
+import type { RelayEvent } from "@/shared/api/types";
+import type { DesktopScope } from "./desktopList";
+
+export type DesktopObservation = { id: string; heard: number };
+export const DESKTOP_PULSE_MS = 60_000;
+
+/** A failed pulse does not hide other Desktops; failed reads retain cached observations. */
+export async function refreshDesktopObservations(
+ scope: DesktopScope,
+ active: () => boolean,
+ ipc = invoke,
+ relay = relayClient,
+) {
+ const epoch = relay.getSessionEpoch();
+ const check = () => {
+ if (!active() || epoch !== relay.getSessionEpoch())
+ throw new Error("Desktop observation scope changed");
+ };
+ const wait = async (work: Promise) => {
+ const result = await work;
+ check();
+ return result;
+ };
+ check();
+ let warning = "";
+ try {
+ const { event } = await wait(
+ ipc<{ event: RelayEvent }>("prepare_desktop_observation", scope),
+ );
+ await wait(
+ relay.publishEvent(
+ event,
+ "Desktop pulse timed out",
+ "Desktop pulse failed",
+ ),
+ );
+ } catch {
+ check();
+ // The next bounded interval retries with a new observation, not an old heartbeat.
+ warning = "This Desktop could not report its last-heard time. Will retry.";
+ }
+ const events = await wait(
+ relay.fetchEvents({ kinds: [30181], authors: [scope.owner], limit: 100 }),
+ );
+ const rows = await wait(
+ ipc("read_desktop_observations", {
+ ...scope,
+ events,
+ }),
+ );
+ return { rows, warning, partial: events.length === 100 };
+}
+
+export function useDesktopObservations(scope: DesktopScope | null) {
+ return useQuery({
+ queryKey: ["desktop-observations", scope?.owner, scope?.community],
+ enabled: !!scope,
+ queryFn: ({ signal }) => {
+ if (!scope) throw new Error("Desktop scope unavailable");
+ return refreshDesktopObservations(scope, () => !signal.aborted);
+ },
+ gcTime: 0,
+ staleTime: DESKTOP_PULSE_MS / 2,
+ retry: false,
+ refetchOnWindowFocus: false,
+ });
+}
+
+/** Signed sender time is advisory, including clock skew; it never establishes agent death. */
+export function desktopFreshness(heard: number | undefined, now: number) {
+ if (heard === undefined) return "Unknown";
+ const state =
+ heard > now
+ ? "Unknown (Desktop clock ahead)"
+ : now - heard <= 180
+ ? "Recent"
+ : "Stale";
+ return `${state} · ${new Date(heard * 1000).toLocaleString()}`;
+}
diff --git a/desktop/src/features/agents/ui/KnownDesktops.tsx b/desktop/src/features/agents/ui/KnownDesktops.tsx
index 1d986d7bfd2..68eeacc1bcb 100644
--- a/desktop/src/features/agents/ui/KnownDesktops.tsx
+++ b/desktop/src/features/agents/ui/KnownDesktops.tsx
@@ -1,25 +1,40 @@
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useIdentityQuery } from "@/shared/api/hooks";
import { useCommunities } from "@/features/communities/useCommunities";
import { relayClient } from "@/shared/api/relayClient";
import { Button } from "@/shared/ui/button";
import { refreshDesktopList, type DesktopList } from "../desktopList";
+import {
+ DESKTOP_PULSE_MS,
+ desktopFreshness,
+ useDesktopObservations,
+ type DesktopObservation,
+} from "../desktopObservations";
type View = {
list: DesktopList | null;
error: boolean;
loading: boolean;
refresh: () => void;
+ observations?: DesktopObservation[];
+ observationWarning?: string;
+ now?: number;
};
-function useDesktopList() {
+function useDesktopScope() {
const owner = useIdentityQuery().data?.pubkey;
const { activeCommunity } = useCommunities();
const community = activeCommunity?.relayUrl
.trim()
.replace(/^http/, "ws")
.replace(/\/+$/, "");
+ return owner && community ? { owner, community } : null;
+}
+
+function useDesktopList() {
+ const scope = useDesktopScope();
+ const { owner, community } = scope ?? {};
return useQuery({
queryKey: ["desktop-profiles", owner, community],
enabled: !!owner && !!community,
@@ -38,31 +53,62 @@ function useDesktopList() {
/** Startup and Agents share the existing owner/community query cache. */
export function DesktopListStartup() {
const { refetch } = useDesktopList();
- useEffect(
- () =>
- relayClient.subscribeToReconnects(() => {
- void refetch();
- }),
- [refetch],
- );
+ const { refetch: pulse } = useDesktopObservations(useDesktopScope());
+ useEffect(() => {
+ const timer = setInterval(() => {
+ void pulse();
+ }, DESKTOP_PULSE_MS);
+ const unsubscribe = relayClient.subscribeToReconnects(() => {
+ void refetch();
+ void pulse();
+ });
+ return () => {
+ clearInterval(timer);
+ unsubscribe();
+ };
+ }, [refetch, pulse]);
return null;
}
export function KnownDesktops() {
const query = useDesktopList();
+ const observations = useDesktopObservations(useDesktopScope());
+ const [now, setNow] = useState(() => Date.now() / 1000);
+ useEffect(() => {
+ const timer = setInterval(() => setNow(Date.now() / 1000), 30_000);
+ return () => clearInterval(timer);
+ }, []);
return (
{
void query.refetch();
+ void observations.refetch();
}}
/>
);
}
-export function DesktopListView({ list, loading, error, refresh }: View) {
+export function DesktopListView({
+ list,
+ loading,
+ error,
+ refresh,
+ observations,
+ observationWarning,
+ now = Date.now() / 1000,
+}: View) {
return (
Private to you. Saved profiles do not indicate whether a Desktop is
- online or ready to run agents.
+ online or ready to run agents. Last heard is a Desktop observation, not
+ proof that its agents are running or stopped.
{loading && Loading Desktop profiles…
}
{error && (
@@ -89,6 +136,7 @@ export function DesktopListView({ list, loading, error, refresh }: View) {
Desktop profiles unavailable. Previously loaded profiles are retained.
)}
+ {observationWarning && {observationWarning}
}
{list?.warning && {list.warning}
}
{list?.partial && (
Partial list: showing up to 100 profiles.
@@ -105,6 +153,13 @@ export function DesktopListView({ list, loading, error, refresh }: View) {
{new Date(row.updated * 1000).toLocaleString()}
+
+ Last heard:{" "}
+ {desktopFreshness(
+ observations?.find((item) => item.id === row.id)?.heard,
+ now,
+ )}
+
))}
diff --git a/migrations/0046_desktop_observation_fts.sql b/migrations/0046_desktop_observation_fts.sql
new file mode 100644
index 00000000000..ba4776fd040
--- /dev/null
+++ b/migrations/0046_desktop_observation_fts.sql
@@ -0,0 +1,26 @@
+-- Owner-private Desktop observations must not enter legacy ciphertext search indexes.
+-- Like 0033, this rewrites events under ACCESS EXCLUSIVE; schedule accordingly.
+DO $$
+DECLARE
+ existing_expression TEXT;
+BEGIN
+ SELECT pg_get_expr(d.adbin, d.adrelid)
+ INTO existing_expression
+ FROM pg_attrdef d
+ JOIN pg_attribute a
+ ON a.attrelid = d.adrelid
+ AND a.attnum = d.adnum
+ WHERE d.adrelid = 'events'::regclass
+ AND a.attname = 'search_tsv';
+
+ IF existing_expression IS NULL THEN
+ RAISE EXCEPTION 'events.search_tsv generated expression not found';
+ END IF;
+
+ ALTER TABLE events DROP COLUMN search_tsv;
+ EXECUTE format(
+ 'ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS (CASE WHEN kind = 30181 THEN NULL::tsvector ELSE (%s) END) STORED',
+ existing_expression
+ );
+ CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv);
+END $$;
diff --git a/schema/schema.sql b/schema/schema.sql
index 59147e4204e..16613480710 100644
--- a/schema/schema.sql
+++ b/schema/schema.sql
@@ -221,7 +221,7 @@ CREATE TABLE events (
-- never matches `@@`.
-- Keep in sync with migrations (final state: 0001 + 0005 + 0014 + 0033).
search_tsv TSVECTOR GENERATED ALWAYS AS (
- CASE WHEN kind IN (1059, 30179, 30180, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector
+ CASE WHEN kind IN (1059, 30179, 30180, 30181, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector
ELSE to_tsvector('simple', content)
END
) STORED,
From 23e8f850c0c68d4de10f35426f2f41c507827b32 Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 11:27:37 -0400
Subject: [PATCH 05/51] fix(desktop): fence delayed pulses and order host
observations
Signed-off-by: Logan Johnson
---
.../agents/desktopObservations.test.mjs | 22 +++++++++
.../features/agents/desktopObservations.ts | 9 +++-
.../api/relayClientPublishRejection.test.mjs | 49 +++++++++++++++++++
desktop/src/shared/api/relayClientSession.ts | 2 +
desktop/src/shared/api/relayEventPublisher.ts | 14 +++++-
5 files changed, 94 insertions(+), 2 deletions(-)
diff --git a/desktop/src/features/agents/desktopObservations.test.mjs b/desktop/src/features/agents/desktopObservations.test.mjs
index af9418365f9..b3166ba25d8 100644
--- a/desktop/src/features/agents/desktopObservations.test.mjs
+++ b/desktop/src/features/agents/desktopObservations.test.mjs
@@ -101,3 +101,25 @@ test("bounded/invalid reads are not silently authoritative, clocks and staleness
])
assert.ok(desktopFreshness(heard, now).startsWith(expected));
});
+
+test("history/live replacement races select newest signed time and lower ID, not arrival", async () => {
+ const f = fixture();
+ const events = [
+ { id: "old", created_at: 10 },
+ { id: "b", created_at: 20 },
+ { id: "a", created_at: 20 },
+ ];
+ const ipc = f.ipc;
+ f.ipc = async (command, args) => {
+ if (command === "read_desktop_observations")
+ assert.deepEqual(
+ args.events.map((e) => e.id),
+ ["a", "b", "old"],
+ );
+ return ipc(command, args);
+ };
+ for (const batch of [events, [...events].reverse()]) {
+ f.relay.fetchEvents = async () => batch;
+ await f.refresh();
+ }
+});
diff --git a/desktop/src/features/agents/desktopObservations.ts b/desktop/src/features/agents/desktopObservations.ts
index 5de30fe8139..d548fa42433 100644
--- a/desktop/src/features/agents/desktopObservations.ts
+++ b/desktop/src/features/agents/desktopObservations.ts
@@ -35,6 +35,7 @@ export async function refreshDesktopObservations(
event,
"Desktop pulse timed out",
"Desktop pulse failed",
+ check,
),
);
} catch {
@@ -48,7 +49,13 @@ export async function refreshDesktopObservations(
const rows = await wait(
ipc("read_desktop_observations", {
...scope,
- events,
+ // History is chronological and may include a live replacement before EOSE.
+ // First matching host wins in the view: newest signed time, then lower ID.
+ events: [...events].sort(
+ (a, b) =>
+ b.created_at - a.created_at ||
+ (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
+ ),
}),
);
return { rows, warning, partial: events.length === 100 };
diff --git a/desktop/src/shared/api/relayClientPublishRejection.test.mjs b/desktop/src/shared/api/relayClientPublishRejection.test.mjs
index 7875b8679ab..f323623ef6f 100644
--- a/desktop/src/shared/api/relayClientPublishRejection.test.mjs
+++ b/desktop/src/shared/api/relayClientPublishRejection.test.mjs
@@ -293,3 +293,52 @@ test("a community switch after send failure cannot retry through its replacement
);
assert.equal(eventFrames().length, 0);
});
+
+test("Desktop pulse cancellation fences rate-limit and reconnect continuations", async () => {
+ const { refreshDesktopObservations } = await import(
+ "../../features/agents/desktopObservations.ts"
+ );
+ for (const boundary of ["rate-limit", "reconnect"]) {
+ reset();
+ const client = connectedClient();
+ const scope = { owner: "owner", community: "wss://a.example" };
+ let active = true;
+ const reconnect = deferred();
+ let reconnecting = false;
+ client.ensureConnected = async () => {
+ reconnecting = true;
+ await reconnect.promise;
+ client.wsId = 8;
+ return client.connectionGeneration;
+ };
+ if (boundary === "rate-limit") activateRateLimit(4);
+ else
+ sendTransport = async () => {
+ throw Error("socket failed");
+ };
+ const result = refreshDesktopObservations(
+ scope,
+ () => active,
+ async (command) => {
+ assert.equal(command, "prepare_desktop_observation");
+ return { event: { id: "pulse", kind: 30181 } };
+ },
+ client,
+ );
+ const rejected = assert.rejects(result, /scope changed/);
+ if (boundary === "reconnect") await flushUntil(() => reconnecting);
+ else await Promise.resolve();
+ active = false;
+ const generation = client.connectionGeneration;
+ resetRateLimitGate();
+ reconnect.resolve();
+ await rejected;
+ assert.equal(eventFrames().length, 0, boundary);
+ assert.equal(client.pendingEvents.size, 0);
+ assert.equal(
+ client.connectionGeneration,
+ generation,
+ "cancellation is not socket failure",
+ );
+ }
+});
diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts
index 93384a40741..3839d01c7e5 100644
--- a/desktop/src/shared/api/relayClientSession.ts
+++ b/desktop/src/shared/api/relayClientSession.ts
@@ -725,6 +725,7 @@ export class RelayClient {
event: RelayEvent,
timeoutMessage: string,
sendErrorMessage: string,
+ assertActive?: () => void,
) {
return publishSessionEvent(
{
@@ -742,6 +743,7 @@ export class RelayClient {
event,
timeoutMessage,
sendErrorMessage,
+ assertActive,
);
}
diff --git a/desktop/src/shared/api/relayEventPublisher.ts b/desktop/src/shared/api/relayEventPublisher.ts
index ff719926700..6f6b6b7c824 100644
--- a/desktop/src/shared/api/relayEventPublisher.ts
+++ b/desktop/src/shared/api/relayEventPublisher.ts
@@ -19,9 +19,11 @@ export async function publishSessionEvent(
event: RelayEvent,
timeoutMessage: string,
sendErrorMessage: string,
+ assertActive: () => void = () => {},
): Promise {
const publishOwnership = session.ownership();
await waitForRateLimit();
+ assertActive();
if (publishOwnership !== session.ownership()) {
throw new Error("Relay disconnected for community switch.");
}
@@ -48,6 +50,14 @@ export async function publishSessionEvent(
return;
}
+ try {
+ assertActive();
+ } catch (error) {
+ window.clearTimeout(timeout);
+ session.pendingEvents.delete(event.id);
+ reject(error);
+ return;
+ }
// Expected socket recovery must not reject the operation being retried.
session.pendingEvents.delete(event.id);
const sendError = session.recoverSocketFailure(error, sendErrorMessage);
@@ -55,7 +65,9 @@ export async function publishSessionEvent(
let retryGeneration: number | null = null;
try {
- retryGeneration = await session.reconnect();
+ const reconnected = await session.reconnect();
+ assertActive();
+ retryGeneration = reconnected;
if (
publishOwnership !== session.ownership() ||
session.generation() !== retryGeneration ||
From 0fdf436105b377ae94d82f9436ed9cdff7ac0675 Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 11:52:30 -0400
Subject: [PATCH 06/51] feat(desktop): report private per-Desktop runtime
capabilities
Signed-off-by: Logan Johnson
---
crates/buzz-core/src/desktop_capabilities.rs | 203 ++++++++++++++++++
crates/buzz-core/src/kind.rs | 4 +
crates/buzz-core/src/lib.rs | 1 +
crates/buzz-db/src/runtime/migration.rs | 19 +-
.../src/api/desktop_profile_postgres_tests.rs | 12 +-
crates/buzz-relay/src/handlers/event.rs | 5 +
crates/buzz-relay/src/handlers/ingest.rs | 17 +-
.../src/commands/desktop_capabilities.rs | 159 ++++++++++++++
.../src/commands/desktop_profiles.rs | 4 +-
desktop/src-tauri/src/commands/mod.rs | 2 +
desktop/src-tauri/src/lib.rs | 2 +
.../agents/desktopCapabilities.test.mjs | 132 ++++++++++++
.../features/agents/desktopCapabilities.ts | 85 ++++++++
.../agents/ui/DesktopCapabilityDetails.tsx | 46 ++++
.../src/features/agents/ui/KnownDesktops.tsx | 30 ++-
migrations/0047_desktop_capabilities_fts.sql | 26 +++
schema/schema.sql | 2 +-
17 files changed, 737 insertions(+), 12 deletions(-)
create mode 100644 crates/buzz-core/src/desktop_capabilities.rs
create mode 100644 desktop/src-tauri/src/commands/desktop_capabilities.rs
create mode 100644 desktop/src/features/agents/desktopCapabilities.test.mjs
create mode 100644 desktop/src/features/agents/desktopCapabilities.ts
create mode 100644 desktop/src/features/agents/ui/DesktopCapabilityDetails.tsx
create mode 100644 migrations/0047_desktop_capabilities_fts.sql
diff --git a/crates/buzz-core/src/desktop_capabilities.rs b/crates/buzz-core/src/desktop_capabilities.rs
new file mode 100644
index 00000000000..c621cca9776
--- /dev/null
+++ b/crates/buzz-core/src/desktop_capabilities.rs
@@ -0,0 +1,203 @@
+//! Bounded, owner-private runtime facts, not signing access or agent readiness.
+use crate::{desktop_profile::DesktopProfile, kind::KIND_DESKTOP_CAPABILITIES};
+use nostr::{nips::nip44, Event, EventBuilder, Keys, Kind, Tag};
+use serde::{Deserialize, Serialize};
+
+/// Allowlisted projection of a built-in runtime; never catalog paths or auth data.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct RuntimeFact {
+ /// Built-in catalog identifier.
+ pub id: String,
+ /// Discovery's installation/adapter availability, not authentication.
+ pub availability: String,
+ /// Whether a separate vendor CLI is required.
+ pub requires_external_cli: bool,
+ /// Spawn policy cap; None means no configured cap, not infinite capacity.
+ pub max_parallelism: Option,
+}
+
+/// Facts at the signed event time, changed only when the projection changes.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct DesktopCapabilities {
+ /// Format version.
+ pub v: u8,
+ /// Encrypted canonical community.
+ pub community: String,
+ /// Local Desktop coordinate.
+ pub id: String,
+ /// Sorted, unique built-in runtime facts.
+ pub runtimes: Vec,
+}
+
+/// Validate the bounded public envelope without decrypting it.
+pub fn validate_envelope(event: &Event) -> Result<(), &'static str> {
+ crate::desktop_profile::validate_private_desktop_envelope(event, KIND_DESKTOP_CAPABILITIES)
+}
+
+impl DesktopCapabilities {
+ /// Project onto the persisted Desktop coordinate, not a caller-selected host.
+ pub fn new(profile: DesktopProfile, mut runtimes: Vec) -> Self {
+ runtimes.sort_by(|a, b| a.id.cmp(&b.id));
+ Self {
+ v: 1,
+ community: profile.community,
+ id: profile.id,
+ runtimes,
+ }
+ }
+
+ fn validate(&self) -> Result<(), String> {
+ DesktopProfile::new(self.community.clone(), self.id.clone())?;
+ if self.v != 1
+ || self.runtimes.len() > 8
+ || self.runtimes.windows(2).any(|r| r[0].id >= r[1].id)
+ || self.runtimes.iter().any(|r| {
+ r.id.is_empty()
+ || r.id.len() > 32
+ || !r.id.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-')
+ || !matches!(
+ r.availability.as_str(),
+ "available"
+ | "adapter_missing"
+ | "adapter_outdated"
+ | "cli_missing"
+ | "not_installed"
+ )
+ || r.max_parallelism == Some(0)
+ })
+ {
+ return Err("invalid Desktop runtime facts".into());
+ }
+ Ok(())
+ }
+
+ /// Encrypt/sign once, then persist these exact bytes for retries.
+ pub fn sign(&self, keys: &Keys) -> Result {
+ self.validate()?;
+ let content = nip44::encrypt(
+ keys.secret_key(),
+ &keys.public_key(),
+ serde_json::to_string(self).map_err(|e| e.to_string())?,
+ nip44::Version::V2,
+ )
+ .map_err(|e| e.to_string())?;
+ let event = EventBuilder::new(Kind::Custom(KIND_DESKTOP_CAPABILITIES as u16), content)
+ .tag(Tag::identifier(&self.id))
+ .sign_with_keys(keys)
+ .map_err(|e| e.to_string())?;
+ validate_envelope(&event)?;
+ Ok(event)
+ }
+
+ /// Bounded history/live merge: newest signed time, lower event ID on ties.
+ pub fn read_latest(
+ mut events: Vec,
+ keys: &Keys,
+ community: &str,
+ ) -> Result, String> {
+ if events.len() > 100 {
+ return Err("too many Desktop reports".into());
+ }
+ events.sort_by(|a, b| b.created_at.cmp(&a.created_at).then(a.id.cmp(&b.id)));
+ let mut seen = std::collections::HashSet::new();
+ let mut rows = Vec::new();
+ for event in events {
+ let report = Self::read(&event, keys, community)?;
+ if seen.insert(report.id.clone()) {
+ rows.push((report, event.created_at.as_secs()));
+ }
+ }
+ Ok(rows)
+ }
+
+ /// Authenticate, decrypt and scope-check before exposing any fact.
+ pub fn read(event: &Event, keys: &Keys, community: &str) -> Result {
+ validate_envelope(event)?;
+ event
+ .verify()
+ .map_err(|_| "invalid Desktop report signature")?;
+ if event.pubkey != keys.public_key() {
+ return Err("foreign Desktop report".into());
+ }
+ let plaintext = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content)
+ .map_err(|_| "Desktop report decryption failed")?;
+ let report: Self =
+ serde_json::from_str(&plaintext).map_err(|_| "invalid Desktop report")?;
+ report.validate()?;
+ if report.community != community || Some(report.id.as_str()) != event.tags.identifier() {
+ return Err("Desktop report scope mismatch".into());
+ }
+ Ok(report)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[test]
+ fn private_scoped_bounded_facts() {
+ let keys = Keys::generate();
+ let mut report = DesktopCapabilities::new(
+ DesktopProfile::new("wss://one.example".into(), "a".repeat(32)).unwrap(),
+ vec![],
+ );
+ let event = report.sign(&keys).unwrap();
+ assert_eq!(
+ DesktopCapabilities::read(&event, &keys, &report.community).unwrap(),
+ report
+ );
+ assert!(DesktopCapabilities::read(&event, &keys, "wss://two.example").is_err());
+ assert!(DesktopCapabilities::read(&event, &Keys::generate(), &report.community).is_err());
+ let mut payload = serde_json::to_value(&report).unwrap();
+ payload["auth"] = serde_json::json!("must not appear");
+ let ciphertext = nip44::encrypt(
+ keys.secret_key(),
+ &keys.public_key(),
+ payload.to_string(),
+ nip44::Version::V2,
+ )
+ .unwrap();
+ let invalid = EventBuilder::new(event.kind, ciphertext)
+ .tags(event.tags.clone())
+ .sign_with_keys(&keys)
+ .unwrap();
+ assert!(DesktopCapabilities::read(&invalid, &keys, &report.community).is_err());
+ let mut tampered = event;
+ tampered.created_at = nostr::Timestamp::from(1);
+ assert!(DesktopCapabilities::read(&tampered, &keys, &report.community).is_err());
+ report.runtimes.push(RuntimeFact {
+ id: "/private/path".into(),
+ availability: "available".into(),
+ requires_external_cli: false,
+ max_parallelism: None,
+ });
+ assert!(report.sign(&keys).is_err());
+ assert!(crate::kind::AUTHOR_ONLY_KINDS.contains(&KIND_DESKTOP_CAPABILITIES));
+ report.runtimes[0].id = "goose".into();
+ let old = report.sign(&keys).unwrap();
+ report.runtimes[0].availability = "cli_missing".into();
+ let new = report.sign(&keys).unwrap();
+ let signed = |event: &Event, time| {
+ EventBuilder::new(event.kind, &event.content)
+ .tags(event.tags.clone())
+ .custom_created_at(nostr::Timestamp::from(time))
+ .sign_with_keys(&keys)
+ .unwrap()
+ };
+ let a = signed(&old, 20);
+ let b = signed(&new, 20);
+ let winner = if a.id < b.id { &a } else { &b };
+ let expected = DesktopCapabilities::read(winner, &keys, &report.community).unwrap();
+ for events in [vec![signed(&old, 10), a.clone(), b.clone()], vec![b, a]] {
+ assert_eq!(
+ DesktopCapabilities::read_latest(events, &keys, &report.community).unwrap(),
+ vec![(expected.clone(), 20)]
+ );
+ }
+ assert!(
+ DesktopCapabilities::read_latest(vec![old; 101], &keys, &report.community).is_err()
+ );
+ }
+}
diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs
index 1e5bfd43d6e..4d309c3b609 100644
--- a/crates/buzz-core/src/kind.rs
+++ b/crates/buzz-core/src/kind.rs
@@ -122,6 +122,8 @@ pub const KIND_DESKTOP_PROFILE: u32 = 30180;
/// Owner-private, per-Desktop last-heard observation; not online or readiness.
pub const KIND_DESKTOP_OBSERVATION: u32 = 30181;
+/// Owner-private built-in runtime facts per Desktop, not agent readiness.
+pub const KIND_DESKTOP_CAPABILITIES: u32 = 30182;
/// Kinds whose stored events are readable only by their author.
///
@@ -138,6 +140,7 @@ pub const AUTHOR_ONLY_KINDS: &[u32] = &[
KIND_PRIVATE_MANAGED_AGENT,
KIND_DESKTOP_PROFILE,
KIND_DESKTOP_OBSERVATION,
+ KIND_DESKTOP_CAPABILITIES,
];
/// Kinds that require a result-level read gate beyond the filter-layer
@@ -669,6 +672,7 @@ pub const ALL_KINDS: &[u32] = &[
KIND_PRIVATE_MANAGED_AGENT,
KIND_DESKTOP_PROFILE,
KIND_DESKTOP_OBSERVATION,
+ KIND_DESKTOP_CAPABILITIES,
KIND_REPORT,
KIND_PRODUCT_FEEDBACK,
KIND_NIP29_PUT_USER,
diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs
index 2105e4a15a7..ee4bb5df789 100644
--- a/crates/buzz-core/src/lib.rs
+++ b/crates/buzz-core/src/lib.rs
@@ -9,6 +9,7 @@
pub mod agent_turn_metric;
/// Channel and membership enums shared across crates.
pub mod channel;
+pub mod desktop_capabilities;
pub mod desktop_observation;
/// Owner-private Desktop display profiles.
pub mod desktop_profile;
diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs
index 5a0d2a64150..7da5d0d0c68 100644
--- a/crates/buzz-db/src/runtime/migration.rs
+++ b/crates/buzz-db/src/runtime/migration.rs
@@ -702,7 +702,7 @@ mod postgres_tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);
- assert_eq!(migrations.len(), 46);
+ assert_eq!(migrations.len(), 47);
assert_eq!(migrations[0].version, 1);
assert_eq!(&*migrations[0].description, "initial schema");
assert!(migrations[0]
@@ -911,7 +911,7 @@ mod postgres_tests {
assert!(migrations[32].sql.as_str().contains("search_tsv"));
assert!(!migrations[0].sql.as_str().contains("30179"));
assert!(include_str!("../../../../schema/schema.sql").contains(
- "kind IN (1059, 30179, 30180, 30181, 30300, 30350, 30622, 44100, 44101, 44200)"
+ "kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200)"
));
// Public push-gateway authority is intentionally deployment-global and
@@ -2393,6 +2393,7 @@ mod postgres_tests {
(3_u8, 30_179_i32),
(4_u8, 30_180_i32),
(5_u8, 30_181_i32),
+ (6_u8, 30_182_i32),
] {
sqlx::query(
"INSERT INTO events \
@@ -2427,6 +2428,7 @@ mod postgres_tests {
(30_179, true),
(30_180, true),
(30_181, true),
+ (30_182, true),
(30_350, true)
]
);
@@ -2451,6 +2453,7 @@ mod postgres_tests {
(30_179, Some(true)),
(30_180, Some(true)),
(30_181, Some(true)),
+ (30_182, Some(true)),
(30_350, None)
]
);
@@ -2479,6 +2482,17 @@ mod postgres_tests {
"0046 must change brownfield observation FTS"
);
+ run_migrations_through(&pool, 46).await.unwrap();
+ let capability_indexed: bool =
+ sqlx::query_scalar("SELECT search_tsv IS NOT NULL FROM events WHERE kind = 30182")
+ .fetch_one(&pool)
+ .await
+ .unwrap();
+ assert!(
+ capability_indexed,
+ "0047 must change brownfield capability FTS"
+ );
+
run_migrations(&pool)
.await
.expect("apply remaining migrations to populated database");
@@ -2496,6 +2510,7 @@ mod postgres_tests {
(30_179, None),
(30_180, None),
(30_181, None),
+ (30_182, None),
(30_350, None)
]
);
diff --git a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs
index 5d863666832..80f4619c583 100644
--- a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs
+++ b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs
@@ -2,7 +2,7 @@
use super::postgres_tests::bridge_handler_test_state;
use super::*;
use axum::{body::Body, http::Request};
-use buzz_core::kind::{KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE};
+use buzz_core::kind::{KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE};
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
use serde_json::json;
use tower::ServiceExt;
@@ -76,6 +76,12 @@ async fn desktop_observation_authenticated_owner_query_and_private_storage() {
assert_private_desktop(KIND_DESKTOP_OBSERVATION).await;
}
+#[tokio::test]
+#[ignore = "requires Postgres"]
+async fn desktop_capabilities_authenticated_owner_query_and_private_storage() {
+ assert_private_desktop(KIND_DESKTOP_CAPABILITIES).await;
+}
+
async fn assert_private_desktop(kind: u32) {
let mut state = bridge_handler_test_state()
.await
@@ -99,6 +105,10 @@ async fn assert_private_desktop(kind: u32) {
let id = profile.id.clone();
let event = if kind == KIND_DESKTOP_PROFILE {
profile.sign(&owner).unwrap()
+ } else if kind == KIND_DESKTOP_CAPABILITIES {
+ buzz_core::desktop_capabilities::DesktopCapabilities::new(profile, vec![])
+ .sign(&owner)
+ .unwrap()
} else {
buzz_core::desktop_observation::DesktopObservation::new(profile)
.sign(&owner)
diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs
index b2f86ec357c..832b5678c9d 100644
--- a/crates/buzz-relay/src/handlers/event.rs
+++ b/crates/buzz-relay/src/handlers/event.rs
@@ -2220,6 +2220,11 @@ mod tests {
assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_OBSERVATION).await;
}
+ #[tokio::test]
+ async fn desktop_capabilities_delivers_to_author_only() {
+ assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_CAPABILITIES).await;
+ }
+
async fn assert_author_only_fanout(kind: u32) {
let state = test_state().await;
diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs
index f60a76fd4a8..9d4e9ad5dc4 100644
--- a/crates/buzz-relay/src/handlers/ingest.rs
+++ b/crates/buzz-relay/src/handlers/ingest.rs
@@ -36,7 +36,7 @@ use buzz_core::kind::{
RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER,
RELAY_ADMIN_SET_WORKSPACE_PROFILE,
};
-use buzz_core::kind::{KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE};
+use buzz_core::kind::{KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE};
use buzz_core::tenant::TenantContext;
use buzz_core::verification::verify_event;
use buzz_core::CommunityId;
@@ -437,7 +437,7 @@ fn map_push_accept_error(error: super::push_lease::AcceptError) -> IngestError {
/// Returns `Err` for unknown kinds — the relay rejects them.
fn required_scope_for_kind(kind: u32, event: &Event) -> Result {
match kind {
- KIND_PROFILE | KIND_DESKTOP_PROFILE | KIND_DESKTOP_OBSERVATION => Ok(Scope::UsersWrite),
+ KIND_PROFILE | KIND_DESKTOP_PROFILE | KIND_DESKTOP_OBSERVATION | KIND_DESKTOP_CAPABILITIES => Ok(Scope::UsersWrite),
KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite),
KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM
| KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT
@@ -660,6 +660,7 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
| KIND_PRIVATE_MANAGED_AGENT
| KIND_DESKTOP_PROFILE
| KIND_DESKTOP_OBSERVATION
+ | KIND_DESKTOP_CAPABILITIES
| KIND_TEAM_CATALOG
// NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope).
// Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag).
@@ -2170,14 +2171,14 @@ pub async fn ingest_event(
result
}
-// Profiles are durable display records, not freshness signals. A Desktop may
+// Profiles and capability facts are durable records, not freshness signals. A Desktop may
// first publish its immutable signed record long after an offline startup.
// Only their past-age bound is waived; future drift and all other admission
// checks still apply. Observation/presence kinds must retain their own window.
fn timestamp_within_ingest_window(kind: u32, event_ts: u64, now: u64) -> bool {
const MAX_TIMESTAMP_DRIFT_SECS: u64 = 900;
event_ts <= now.saturating_add(MAX_TIMESTAMP_DRIFT_SECS)
- && (kind == KIND_DESKTOP_PROFILE
+ && (matches!(kind, KIND_DESKTOP_PROFILE | KIND_DESKTOP_CAPABILITIES)
|| now.saturating_sub(event_ts) <= MAX_TIMESTAMP_DRIFT_SECS)
}
@@ -2793,6 +2794,11 @@ async fn ingest_event_inner(
}
}
+ if kind_u32 == KIND_DESKTOP_CAPABILITIES {
+ buzz_core::desktop_capabilities::validate_envelope(&event)
+ .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
+ }
+
if kind_u32 == KIND_DESKTOP_OBSERVATION {
buzz_core::desktop_observation::validate_envelope(&event)
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
@@ -3338,6 +3344,7 @@ mod postgres_tests {
// Include the next observation kind explicitly: freshness is not profile age.
for kind in [
KIND_DESKTOP_PROFILE,
+ KIND_DESKTOP_CAPABILITIES,
30181,
KIND_PROFILE,
KIND_EVENT_REMINDER,
@@ -3355,7 +3362,7 @@ mod postgres_tests {
] {
assert_eq!(
timestamp_within_ingest_window(kind, timestamp, now),
- if kind == KIND_DESKTOP_PROFILE {
+ if matches!(kind, KIND_DESKTOP_PROFILE | KIND_DESKTOP_CAPABILITIES) {
profile
} else {
ordinary
diff --git a/desktop/src-tauri/src/commands/desktop_capabilities.rs b/desktop/src-tauri/src/commands/desktop_capabilities.rs
new file mode 100644
index 00000000000..bdab02274eb
--- /dev/null
+++ b/desktop/src-tauri/src/commands/desktop_capabilities.rs
@@ -0,0 +1,159 @@
+//! Private Desktop reports reuse the local catalog authority and retention scope.
+use super::desktop_profiles::{prepare, scope};
+use crate::{
+ app_state::AppState,
+ managed_agents::{
+ retention::{open_retention_db, RetentionScope},
+ AcpRuntimeCatalogEntry, HarnessSource,
+ },
+};
+use buzz_core_pkg::{
+ desktop_capabilities::{DesktopCapabilities, RuntimeFact},
+ desktop_profile::DesktopProfile,
+};
+use nostr::{Event, JsonUtil};
+use rusqlite::{Connection, OptionalExtension, TransactionBehavior};
+use serde_json::{json, Value};
+use tauri::{AppHandle, State};
+
+fn project(catalog: Vec) -> Result, String> {
+ catalog
+ .into_iter()
+ .filter(|r| r.source == HarnessSource::Builtin)
+ .map(|r| {
+ Ok(RuntimeFact {
+ id: r.id,
+ availability: serde_json::from_value(
+ serde_json::to_value(r.availability).map_err(|e| e.to_string())?,
+ )
+ .map_err(|e| e.to_string())?,
+ requires_external_cli: r.requires_external_cli,
+ max_parallelism: r.max_parallelism,
+ })
+ })
+ .collect()
+}
+
+/// Cached discovery only; Settings → Agents remains the local setup/check-again UI.
+#[tauri::command]
+pub async fn prepare_desktop_capabilities(
+ app: AppHandle,
+ state: State<'_, AppState>,
+ owner: String,
+ community: String,
+) -> Result {
+ // Serialize discovery + persistence so an older native completion cannot
+ // overwrite a newer projection when observers cancel/restart.
+ static SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
+ let _guard = SERIAL.lock().await;
+ scope(&app, &state, &owner, &community)?;
+ let facts =
+ project(super::agent_discovery::discover_acp_providers(app.clone(), Some(false)).await?)?;
+ let scope = scope(&app, &state, &owner, &community)?;
+ Ok(json!({ "event": prepare_report(&mut open_retention_db(&scope.db_path)?, &scope, facts)? }))
+}
+
+fn prepare_report(
+ conn: &mut Connection,
+ scope: &RetentionScope,
+ facts: Vec,
+) -> Result {
+ let saved = prepare(conn, scope)?;
+ let profile: Event =
+ serde_json::from_value(saved["event"].clone()).map_err(|e| e.to_string())?;
+ let community = scope.relay_url.trim_end_matches('/');
+ let report = DesktopCapabilities::new(
+ DesktopProfile::read(&profile, &scope.owner_keys, community)?,
+ facts,
+ );
+ let tx = conn
+ .transaction_with_behavior(TransactionBehavior::Immediate)
+ .map_err(|e| e.to_string())?;
+ tx.execute_batch("CREATE TABLE IF NOT EXISTS desktop_capabilities (slot INTEGER PRIMARY KEY CHECK(slot = 1), raw TEXT NOT NULL);").map_err(|e| e.to_string())?;
+ let raw: Option = tx
+ .query_row(
+ "SELECT raw FROM desktop_capabilities WHERE slot = 1",
+ [],
+ |row| row.get(0),
+ )
+ .optional()
+ .map_err(|e| e.to_string())?;
+ let previous = raw
+ .map(|raw| Event::from_json(raw).map_err(|e| e.to_string()))
+ .transpose()?;
+ let unchanged = previous
+ .as_ref()
+ .map(|e| {
+ DesktopCapabilities::read(e, &scope.owner_keys, community).map(|old| old == report)
+ })
+ .transpose()?
+ .unwrap_or(false);
+ let event = match previous {
+ Some(event) if unchanged => event,
+ _ => {
+ let event = report.sign(&scope.owner_keys)?;
+ tx.execute(
+ "INSERT OR REPLACE INTO desktop_capabilities VALUES (1, ?1)",
+ [event.as_json()],
+ )
+ .map_err(|e| e.to_string())?;
+ event
+ }
+ };
+ tx.commit().map_err(|e| e.to_string())?;
+ Ok(event)
+}
+
+/// Read only verified owner/community reports, newest signed time then lower ID.
+#[tauri::command]
+pub fn read_desktop_capabilities(
+ app: AppHandle,
+ state: State<'_, AppState>,
+ owner: String,
+ community: String,
+ events: Vec,
+) -> Result {
+ let scope = scope(&app, &state, &owner, &community)?;
+ let rows: Vec<_> = DesktopCapabilities::read_latest(events, &scope.owner_keys, &community)?.into_iter()
+ .map(|(report, reported)| json!({ "id": report.id, "reported": reported, "runtimes": report.runtimes })).collect();
+ Ok(json!(rows))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[test]
+ fn unchanged_facts_reopen_exact_bytes_changed_facts_replace_atomically() {
+ let dir = tempfile::tempdir().unwrap();
+ let scope = RetentionScope {
+ db_path: dir.path().join("report.db"),
+ relay_url: "wss://one.example".into(),
+ owner_keys: nostr::Keys::generate(),
+ };
+ let first = prepare_report(
+ &mut open_retention_db(&scope.db_path).unwrap(),
+ &scope,
+ vec![],
+ )
+ .unwrap();
+ let mut reopened = open_retention_db(&scope.db_path).unwrap();
+ assert_eq!(
+ prepare_report(&mut reopened, &scope, vec![]).unwrap(),
+ first
+ );
+ assert_eq!(reopened.total_changes(), 0);
+ let facts = vec![RuntimeFact {
+ id: "goose".into(),
+ availability: "available".into(),
+ requires_external_cli: true,
+ max_parallelism: None,
+ }];
+ let changed = prepare_report(&mut reopened, &scope, facts).unwrap();
+ assert_ne!(changed.id, first.id);
+ assert_eq!(changed.tags, first.tags);
+ reopened
+ .execute("UPDATE desktop_capabilities SET raw = 'corrupt'", [])
+ .unwrap();
+ assert!(prepare_report(&mut reopened, &scope, vec![]).is_err());
+ }
+}
diff --git a/desktop/src-tauri/src/commands/desktop_profiles.rs b/desktop/src-tauri/src/commands/desktop_profiles.rs
index d3438d71c51..caec1f9af88 100644
--- a/desktop/src-tauri/src/commands/desktop_profiles.rs
+++ b/desktop/src-tauri/src/commands/desktop_profiles.rs
@@ -8,7 +8,7 @@ use tauri::{AppHandle, State};
use crate::app_state::AppState;
use crate::managed_agents::retention::{active_retention_scope, open_retention_db, RetentionScope};
-fn scope(
+pub(super) fn scope(
app: &AppHandle,
state: &AppState,
owner: &str,
@@ -23,7 +23,7 @@ fn scope(
Ok(scope)
}
-fn prepare(conn: &mut Connection, scope: &RetentionScope) -> Result {
+pub(super) fn prepare(conn: &mut Connection, scope: &RetentionScope) -> Result {
// SQLite serializes concurrent startup/open requests across processes. The ID
// and exact ciphertext/signature commit together, before any network write.
let tx = conn
diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs
index 3e3c38a3e8c..0cb37bf7e02 100644
--- a/desktop/src-tauri/src/commands/mod.rs
+++ b/desktop/src-tauri/src/commands/mod.rs
@@ -18,6 +18,7 @@ mod channel_templates;
mod channel_window;
mod channels;
mod clipboard;
+mod desktop_capabilities;
mod desktop_profiles;
mod dms;
mod engrams;
@@ -93,6 +94,7 @@ pub use channel_templates::*;
pub use channel_window::*;
pub use channels::*;
pub use clipboard::*;
+pub use desktop_capabilities::*;
pub use desktop_profiles::*;
pub use dms::*;
pub use engrams::*;
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index edffed5e462..f794243354d 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -556,6 +556,8 @@ pub fn run() {
read_desktop_profiles,
prepare_desktop_observation,
read_desktop_observations,
+ prepare_desktop_capabilities,
+ read_desktop_capabilities,
get_nsec,
generate_backup_passphrase,
create_ncryptsec_backup,
diff --git a/desktop/src/features/agents/desktopCapabilities.test.mjs b/desktop/src/features/agents/desktopCapabilities.test.mjs
new file mode 100644
index 00000000000..9def2ec43d8
--- /dev/null
+++ b/desktop/src/features/agents/desktopCapabilities.test.mjs
@@ -0,0 +1,132 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import React from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { refreshDesktopCapabilities } from "./desktopCapabilities.ts";
+import { DesktopListView } from "./ui/KnownDesktops.tsx";
+
+const scope = { owner: "owner-a", community: "wss://one.example" };
+const event = { id: "signed", created_at: 100, kind: 30182 };
+const row = {
+ id: "desktop-a",
+ reported: 100,
+ runtimes: [
+ {
+ id: "goose",
+ availability: "cli_missing",
+ requires_external_cli: true,
+ max_parallelism: null,
+ },
+ ],
+};
+function fixture(boundary) {
+ let epoch = 0;
+ const calls = [];
+ const finish = (name, result) => {
+ if (name === boundary) epoch++;
+ return result;
+ };
+ const f = {
+ calls,
+ head: [],
+ ipc: async (command, args) => {
+ assert.equal(args.owner, scope.owner);
+ assert.equal(args.community, scope.community);
+ calls.push(command);
+ if (command === "prepare_desktop_capabilities")
+ return finish("prepare", { event });
+ assert.equal(command, "read_desktop_capabilities");
+ return finish(
+ "read",
+ args.events.map(() => row),
+ );
+ },
+ relay: {
+ getSessionEpoch: () => epoch,
+ fetchEvents: async (filter) => {
+ assert.deepEqual(filter.authors, [scope.owner]);
+ assert.deepEqual(filter.kinds, [30182]);
+ assert.ok(filter.limit <= 100);
+ return finish("fetch", filter["#d"] ? f.head : [event]);
+ },
+ publishEvent: async (value, _timeout, _failure, check) => {
+ finish("transport");
+ check(); // Delayed transport must invoke the production cancellation guard.
+ calls.push(value);
+ f.head = [value];
+ finish("ack");
+ },
+ },
+ };
+ f.refresh = (active = () => true) =>
+ refreshDesktopCapabilities(scope, active, f.ipc, f.relay);
+ return f;
+}
+
+test("unchanged accepted report does not republish; failed publish retries exact bytes", async () => {
+ const f = fixture();
+ assert.deepEqual((await f.refresh()).rows, [row]);
+ await f.refresh();
+ assert.equal(f.calls.filter((c) => c === event).length, 1);
+ f.head = [];
+ f.relay.publishEvent = async (value) => {
+ assert.equal(value, event);
+ throw Error("offline");
+ };
+ for (let i = 0; i < 2; i++) assert.ok((await f.refresh()).warning);
+ f.relay.fetchEvents = async () => {
+ throw Error("unavailable");
+ };
+ await assert.rejects(f.refresh(), /unavailable/);
+});
+
+test("all async boundaries fence cancellation, account/community switches and late ACK", async () => {
+ for (const boundary of ["prepare", "read", "fetch", "transport", "ack"]) {
+ const f = fixture(boundary);
+ await assert.rejects(f.refresh(), /scope changed/);
+ if (boundary !== "ack") assert.ok(!f.calls.includes(event));
+ }
+ const f = fixture();
+ await assert.rejects(f.refresh(() => false));
+ assert.deepEqual(f.calls, []);
+ f.relay.fetchEvents = async () => Array(100).fill(event);
+ assert.equal((await f.refresh()).partial, true);
+ f.ipc = async () => {
+ throw Error("invalid signature");
+ };
+ await assert.rejects(f.refresh(), /invalid signature/);
+});
+
+test("mounted Desktop rows show exact remote facts and unknowns, not readiness", () => {
+ const html = renderToStaticMarkup(
+ React.createElement(DesktopListView, {
+ list: {
+ rows: ["desktop-a", "desktop-b"].map((id) => ({
+ id,
+ name: id,
+ updated: 1,
+ })),
+ local: "desktop-b",
+ },
+ capabilities: [row],
+ now: 99,
+ refresh() {},
+ loading: false,
+ error: false,
+ }),
+ );
+ assert.equal(
+ (html.match(/Capability details<\/summary>/g) ?? []).length,
+ 2,
+ );
+ for (const text of [
+ "goose",
+ "cli missing",
+ "not configured",
+ "Desktop clock ahead",
+ "No capability report received",
+ "not agent readiness",
+ "Settings",
+ ])
+ assert.ok(html.includes(text), text);
+});
diff --git a/desktop/src/features/agents/desktopCapabilities.ts b/desktop/src/features/agents/desktopCapabilities.ts
new file mode 100644
index 00000000000..566ef2b8117
--- /dev/null
+++ b/desktop/src/features/agents/desktopCapabilities.ts
@@ -0,0 +1,85 @@
+import { invoke } from "@tauri-apps/api/core";
+import { useQuery } from "@tanstack/react-query";
+import { relayClient } from "@/shared/api/relayClient";
+import type { RelayEvent } from "@/shared/api/types";
+import type { DesktopScope } from "./desktopList";
+
+export type DesktopCapabilities = {
+ id: string;
+ reported: number;
+ runtimes: {
+ id: string;
+ availability: string;
+ requires_external_cli: boolean;
+ max_parallelism: number | null;
+ }[];
+};
+
+/** Exact signed reports are persisted natively; only changed facts create new bytes. */
+export async function refreshDesktopCapabilities(
+ scope: DesktopScope,
+ active: () => boolean,
+ ipc = invoke,
+ relay = relayClient,
+) {
+ const epoch = relay.getSessionEpoch();
+ const check = () => {
+ if (!active() || epoch !== relay.getSessionEpoch())
+ throw new Error("Desktop capability scope changed");
+ };
+ const wait = async (work: Promise) => {
+ const result = await work;
+ check();
+ return result;
+ };
+ const read = (events: RelayEvent[]) =>
+ wait(
+ ipc("read_desktop_capabilities", {
+ ...scope,
+ events,
+ }),
+ );
+ const filter = { kinds: [30182], authors: [scope.owner] };
+ check();
+ let warning = "";
+ try {
+ const { event } = await wait(
+ ipc<{ event: RelayEvent }>("prepare_desktop_capabilities", scope),
+ );
+ const [local] = await read([event]);
+ const head = await wait(
+ relay.fetchEvents({ ...filter, "#d": [local.id], limit: 1 }),
+ );
+ await read(head);
+ if (!head.some((e) => e.id === event.id))
+ await wait(
+ relay.publishEvent(
+ event,
+ "Desktop report timed out",
+ "Desktop report failed",
+ check,
+ ),
+ );
+ } catch {
+ check();
+ warning =
+ "This Desktop could not synchronize capability facts. Will retry.";
+ }
+ const events = await wait(relay.fetchEvents({ ...filter, limit: 100 }));
+ return { rows: await read(events), partial: events.length === 100, warning };
+}
+
+export function useDesktopCapabilities(scope: DesktopScope | null) {
+ return useQuery({
+ queryKey: ["desktop-capabilities", scope?.owner, scope?.community],
+ enabled: !!scope,
+ queryFn: ({ signal }) => {
+ if (!scope) throw new Error("Desktop scope unavailable");
+ return refreshDesktopCapabilities(scope, () => !signal.aborted);
+ },
+ gcTime: 0,
+ staleTime: 30_000,
+ retry: false,
+ refetchOnWindowFocus: false,
+ });
+}
diff --git a/desktop/src/features/agents/ui/DesktopCapabilityDetails.tsx b/desktop/src/features/agents/ui/DesktopCapabilityDetails.tsx
new file mode 100644
index 00000000000..aa18aed7a3d
--- /dev/null
+++ b/desktop/src/features/agents/ui/DesktopCapabilityDetails.tsx
@@ -0,0 +1,46 @@
+import type { DesktopCapabilities } from "../desktopCapabilities";
+
+/** Read-only remote projection; local setup remains in Settings → Agents. */
+export function DesktopCapabilityDetails({
+ report,
+ now,
+}: {
+ report?: DesktopCapabilities;
+ now: number;
+}) {
+ return (
+
+ Capability details
+ {!report ? (
+ No capability report received.
+ ) : (
+ <>
+
+ Facts reported {new Date(report.reported * 1000).toLocaleString()}
+ {report.reported > now &&
+ " (Desktop clock ahead; report time uncertain)"}
+ . Unchanged facts keep their original report time.
+
+
+ {report.runtimes.map((runtime) => (
+
+ {runtime.id}: {runtime.availability.replaceAll("_", " ")} ·
+ external CLI{" "}
+ {runtime.requires_external_cli ? "required" : "not required"} ·
+ parallelism cap {runtime.max_parallelism ?? "not configured"}.
+
+ ))}
+
+ {!report.runtimes.length && (
+ No built-in runtime facts reported.
+ )}
+ >
+ )}
+
+ Cached installation facts only, not agent readiness or access to an
+ agent’s signing key. Stable agent keys must be provisioned separately by
+ you. For local setup and Check again, use Settings → Agents.
+
+
+ );
+}
diff --git a/desktop/src/features/agents/ui/KnownDesktops.tsx b/desktop/src/features/agents/ui/KnownDesktops.tsx
index 68eeacc1bcb..16d872c2d8b 100644
--- a/desktop/src/features/agents/ui/KnownDesktops.tsx
+++ b/desktop/src/features/agents/ui/KnownDesktops.tsx
@@ -12,7 +12,15 @@ import {
type DesktopObservation,
} from "../desktopObservations";
+import {
+ useDesktopCapabilities,
+ type DesktopCapabilities,
+} from "../desktopCapabilities";
+import { DesktopCapabilityDetails } from "./DesktopCapabilityDetails";
+
type View = {
+ capabilities?: DesktopCapabilities[];
+ capabilityWarning?: string;
list: DesktopList | null;
error: boolean;
loading: boolean;
@@ -54,25 +62,29 @@ function useDesktopList() {
export function DesktopListStartup() {
const { refetch } = useDesktopList();
const { refetch: pulse } = useDesktopObservations(useDesktopScope());
+ const { refetch: report } = useDesktopCapabilities(useDesktopScope());
useEffect(() => {
const timer = setInterval(() => {
void pulse();
+ void report();
}, DESKTOP_PULSE_MS);
const unsubscribe = relayClient.subscribeToReconnects(() => {
void refetch();
void pulse();
+ void report();
});
return () => {
clearInterval(timer);
unsubscribe();
};
- }, [refetch, pulse]);
+ }, [refetch, pulse, report]);
return null;
}
export function KnownDesktops() {
const query = useDesktopList();
const observations = useDesktopObservations(useDesktopScope());
+ const capabilities = useDesktopCapabilities(useDesktopScope());
const [now, setNow] = useState(() => Date.now() / 1000);
useEffect(() => {
const timer = setInterval(() => setNow(Date.now() / 1000), 30_000);
@@ -80,6 +92,14 @@ export function KnownDesktops() {
}, []);
return (
{
void query.refetch();
void observations.refetch();
+ void capabilities.refetch();
}}
/>
);
@@ -107,6 +128,8 @@ export function DesktopListView({
refresh,
observations,
observationWarning,
+ capabilities,
+ capabilityWarning,
now = Date.now() / 1000,
}: View) {
return (
@@ -136,6 +159,7 @@ export function DesktopListView({
Desktop profiles unavailable. Previously loaded profiles are retained.
)}
+ {capabilityWarning && {capabilityWarning}
}
{observationWarning && {observationWarning}
}
{list?.warning && {list.warning}
}
{list?.partial && (
@@ -160,6 +184,10 @@ export function DesktopListView({
now,
)}
+ item.id === row.id)}
+ now={now}
+ />
))}
diff --git a/migrations/0047_desktop_capabilities_fts.sql b/migrations/0047_desktop_capabilities_fts.sql
new file mode 100644
index 00000000000..425d1eee92b
--- /dev/null
+++ b/migrations/0047_desktop_capabilities_fts.sql
@@ -0,0 +1,26 @@
+-- Owner-private Desktop capability reports must not enter legacy ciphertext search indexes.
+-- Like 0033, this rewrites events under ACCESS EXCLUSIVE; schedule accordingly.
+DO $$
+DECLARE
+ existing_expression TEXT;
+BEGIN
+ SELECT pg_get_expr(d.adbin, d.adrelid)
+ INTO existing_expression
+ FROM pg_attrdef d
+ JOIN pg_attribute a
+ ON a.attrelid = d.adrelid
+ AND a.attnum = d.adnum
+ WHERE d.adrelid = 'events'::regclass
+ AND a.attname = 'search_tsv';
+
+ IF existing_expression IS NULL THEN
+ RAISE EXCEPTION 'events.search_tsv generated expression not found';
+ END IF;
+
+ ALTER TABLE events DROP COLUMN search_tsv;
+ EXECUTE format(
+ 'ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS (CASE WHEN kind = 30182 THEN NULL::tsvector ELSE (%s) END) STORED',
+ existing_expression
+ );
+ CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv);
+END $$;
diff --git a/schema/schema.sql b/schema/schema.sql
index 16613480710..d78b3036d97 100644
--- a/schema/schema.sql
+++ b/schema/schema.sql
@@ -221,7 +221,7 @@ CREATE TABLE events (
-- never matches `@@`.
-- Keep in sync with migrations (final state: 0001 + 0005 + 0014 + 0033).
search_tsv TSVECTOR GENERATED ALWAYS AS (
- CASE WHEN kind IN (1059, 30179, 30180, 30181, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector
+ CASE WHEN kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector
ELSE to_tsvector('simple', content)
END
) STORED,
From 09282c7283e48422e4c7f99eeb77989346a563ca Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 12:24:55 -0400
Subject: [PATCH 07/51] fix(desktop): defer capability changes until real time
advances
Signed-off-by: Logan Johnson
---
crates/buzz-core/src/desktop_capabilities.rs | 6 ++
.../src/commands/desktop_capabilities.rs | 83 +++++++++++++++++--
.../agents/desktopCapabilities.test.mjs | 28 +++++++
.../src/features/agents/desktopList.test.mjs | 34 +++++++-
4 files changed, 141 insertions(+), 10 deletions(-)
diff --git a/crates/buzz-core/src/desktop_capabilities.rs b/crates/buzz-core/src/desktop_capabilities.rs
index c621cca9776..ef1c4d8f63a 100644
--- a/crates/buzz-core/src/desktop_capabilities.rs
+++ b/crates/buzz-core/src/desktop_capabilities.rs
@@ -75,6 +75,11 @@ impl DesktopCapabilities {
/// Encrypt/sign once, then persist these exact bytes for retries.
pub fn sign(&self, keys: &Keys) -> Result {
+ self.sign_at(keys, nostr::Timestamp::now())
+ }
+
+ /// Sign at an observed wall-clock second, never a synthesized logical time.
+ pub fn sign_at(&self, keys: &Keys, observed: nostr::Timestamp) -> Result {
self.validate()?;
let content = nip44::encrypt(
keys.secret_key(),
@@ -85,6 +90,7 @@ impl DesktopCapabilities {
.map_err(|e| e.to_string())?;
let event = EventBuilder::new(Kind::Custom(KIND_DESKTOP_CAPABILITIES as u16), content)
.tag(Tag::identifier(&self.id))
+ .custom_created_at(observed)
.sign_with_keys(keys)
.map_err(|e| e.to_string())?;
validate_envelope(&event)?;
diff --git a/desktop/src-tauri/src/commands/desktop_capabilities.rs b/desktop/src-tauri/src/commands/desktop_capabilities.rs
index bdab02274eb..f5efd5c953b 100644
--- a/desktop/src-tauri/src/commands/desktop_capabilities.rs
+++ b/desktop/src-tauri/src/commands/desktop_capabilities.rs
@@ -50,13 +50,16 @@ pub async fn prepare_desktop_capabilities(
let facts =
project(super::agent_discovery::discover_acp_providers(app.clone(), Some(false)).await?)?;
let scope = scope(&app, &state, &owner, &community)?;
- Ok(json!({ "event": prepare_report(&mut open_retention_db(&scope.db_path)?, &scope, facts)? }))
+ Ok(
+ json!({ "event": prepare_report(&mut open_retention_db(&scope.db_path)?, &scope, facts, nostr::Timestamp::now)? }),
+ )
}
fn prepare_report(
conn: &mut Connection,
scope: &RetentionScope,
facts: Vec,
+ clock: impl FnOnce() -> nostr::Timestamp,
) -> Result {
let saved = prepare(conn, scope)?;
let profile: Event =
@@ -90,8 +93,16 @@ fn prepare_report(
.unwrap_or(false);
let event = match previous {
Some(event) if unchanged => event,
- _ => {
- let event = report.sign(&scope.owner_keys)?;
+ previous => {
+ let now = clock();
+ // Keep the prior retry record until real time advances. Signing tied
+ // ciphertext can lose NIP-33's lower-ID tie; never cache that loss or
+ // future-date a replacement. The existing pulse/reconnect/Refresh
+ // retries discovery, not a captured projection, without waiting here.
+ if previous.as_ref().is_some_and(|e| now <= e.created_at) {
+ return Err("Desktop capability facts deferred until the clock advances".into());
+ }
+ let event = report.sign_at(&scope.owner_keys, now)?;
tx.execute(
"INSERT OR REPLACE INTO desktop_capabilities VALUES (1, ?1)",
[event.as_json()],
@@ -123,7 +134,7 @@ pub fn read_desktop_capabilities(
mod tests {
use super::*;
#[test]
- fn unchanged_facts_reopen_exact_bytes_changed_facts_replace_atomically() {
+ fn changed_facts_defer_until_real_clock_advances_then_win_signed_order() {
let dir = tempfile::tempdir().unwrap();
let scope = RetentionScope {
db_path: dir.path().join("report.db"),
@@ -134,26 +145,80 @@ mod tests {
&mut open_retention_db(&scope.db_path).unwrap(),
&scope,
vec![],
+ || nostr::Timestamp::from(1000),
)
.unwrap();
let mut reopened = open_retention_db(&scope.db_path).unwrap();
assert_eq!(
- prepare_report(&mut reopened, &scope, vec![]).unwrap(),
+ prepare_report(&mut reopened, &scope, vec![], || panic!(
+ "unchanged must not sign"
+ ))
+ .unwrap(),
first
);
assert_eq!(reopened.total_changes(), 0);
- let facts = vec![RuntimeFact {
+ let mut facts = vec![RuntimeFact {
id: "goose".into(),
availability: "available".into(),
requires_external_cli: true,
max_parallelism: None,
}];
- let changed = prepare_report(&mut reopened, &scope, facts).unwrap();
- assert_ne!(changed.id, first.id);
+ for now in [1000, 990, 999, 1000] {
+ let error = prepare_report(&mut reopened, &scope, facts.clone(), || {
+ nostr::Timestamp::from(now)
+ })
+ .unwrap_err();
+ assert!(error.contains("clock advances"));
+ assert_eq!(reopened.total_changes(), 0, "deferral must not persist");
+ // Returning to old facts cancels the proposed change, even after a
+ // restart/rollback: no deferred payload or timestamp renewal survives.
+ assert_eq!(
+ prepare_report(&mut reopened, &scope, vec![], || panic!("exact retry")).unwrap(),
+ first
+ );
+ reopened = open_retention_db(&scope.db_path).unwrap();
+ }
+ // The retry observes today's facts, not the projection first deferred.
+ facts[0].availability = "cli_missing".into();
+ let changed = prepare_report(&mut reopened, &scope, facts.clone(), || {
+ nostr::Timestamp::from(1001)
+ })
+ .unwrap();
+ first.verify().unwrap();
+ changed.verify().unwrap();
+ assert_eq!(changed.created_at.as_secs(), 1001, "no future timestamp");
+ assert!(changed.created_at > first.created_at);
assert_eq!(changed.tags, first.tags);
+ for events in [
+ vec![first.clone(), changed.clone()],
+ vec![changed.clone(), first],
+ ] {
+ let rows =
+ DesktopCapabilities::read_latest(events, &scope.owner_keys, &scope.relay_url)
+ .unwrap();
+ assert_eq!(rows.len(), 1);
+ assert_eq!(rows[0].0.runtimes, facts);
+ assert_eq!(rows[0].1, 1001);
+ }
+ let mut reopened = open_retention_db(&scope.db_path).unwrap();
+ let mut invalid = facts.clone();
+ invalid[0].max_parallelism = Some(0);
+ assert!(prepare_report(&mut reopened, &scope, invalid, || {
+ nostr::Timestamp::from(1002)
+ })
+ .is_err());
+ assert_eq!(
+ prepare_report(&mut reopened, &scope, facts, || panic!("exact retry")).unwrap(),
+ changed
+ );
+ assert_eq!(
+ reopened.total_changes(),
+ 0,
+ "failed signing must not persist"
+ );
reopened
.execute("UPDATE desktop_capabilities SET raw = 'corrupt'", [])
.unwrap();
- assert!(prepare_report(&mut reopened, &scope, vec![]).is_err());
+ assert!(prepare_report(&mut reopened, &scope, vec![], nostr::Timestamp::now).is_err());
}
}
diff --git a/desktop/src/features/agents/desktopCapabilities.test.mjs b/desktop/src/features/agents/desktopCapabilities.test.mjs
index 9def2ec43d8..da09f6bb4a8 100644
--- a/desktop/src/features/agents/desktopCapabilities.test.mjs
+++ b/desktop/src/features/agents/desktopCapabilities.test.mjs
@@ -80,6 +80,34 @@ test("unchanged accepted report does not republish; failed publish retries exact
await assert.rejects(f.refresh(), /unavailable/);
});
+test("deferred preparation settles with prior relay facts, never publishes, and honors cancellation", async () => {
+ const f = fixture();
+ const ipc = f.ipc;
+ let active = true;
+ let cancel = false;
+ f.ipc = async (command, args) => {
+ if (command === "prepare_desktop_capabilities") {
+ if (cancel) active = false;
+ throw Error("Desktop capability facts deferred until the clock advances");
+ }
+ return ipc(command, args);
+ };
+ const deferred = await f.refresh(() => active);
+ assert.deepEqual(deferred.rows, [row]);
+ assert.match(deferred.warning, /Will retry/);
+ assert.ok(!f.calls.includes(event));
+ cancel = true;
+ await assert.rejects(
+ f.refresh(() => active),
+ /scope changed/,
+ );
+ assert.ok(!f.calls.includes(event));
+ // A later, active attempt prepares afresh; no held promise or queued event.
+ f.ipc = ipc;
+ assert.equal((await f.refresh()).warning, "");
+ assert.equal(f.calls.filter((c) => c === event).length, 1);
+});
+
test("all async boundaries fence cancellation, account/community switches and late ACK", async () => {
for (const boundary of ["prepare", "read", "fetch", "transport", "ack"]) {
const f = fixture(boundary);
diff --git a/desktop/src/features/agents/desktopList.test.mjs b/desktop/src/features/agents/desktopList.test.mjs
index 6f7330d978c..5e21adbb316 100644
--- a/desktop/src/features/agents/desktopList.test.mjs
+++ b/desktop/src/features/agents/desktopList.test.mjs
@@ -202,6 +202,9 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa
const originalReconnect = relayClient.subscribeToReconnects;
let reconnect;
let pulses = 0;
+ let reports = 0;
+ let publishedReports = 0;
+ let deferReport = true;
relayClient.subscribeToReconnects = (callback) => {
reconnect = callback;
return () => {
@@ -210,6 +213,17 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa
};
window.__TAURI_INTERNALS__ = {
invoke: async (command, args) => {
+ if (command === "prepare_desktop_capabilities") {
+ reports++;
+ if (deferReport) throw Error("clock has not advanced");
+ return { event: { ...first, kind: 30182 } };
+ }
+ if (command === "read_desktop_capabilities")
+ return args.events.map(() => ({
+ id: "desktop-a",
+ reported: 100,
+ runtimes: [],
+ }));
if (command === "prepare_desktop_observation")
return { event: { ...first, kind: 30181 } };
if (command === "read_desktop_observations")
@@ -237,7 +251,7 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa
};
relayClient.fetchEvents = async (filter) => {
if (fail) throw Error("unavailable");
- if (filter.kinds[0] === 30181) return [];
+ if ([30181, 30182].includes(filter.kinds[0])) return [];
if (filter["#d"]) return [current];
const rows = [current];
if (hold) {
@@ -249,6 +263,10 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa
return rows;
};
relayClient.publishEvent = async (event) => {
+ if (event.kind === 30182) {
+ publishedReports++;
+ return;
+ }
assert.equal(event.kind, 30181, "no profile heartbeat rewrite");
pulses++;
};
@@ -285,14 +303,28 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa
await settle();
assert.match(text(), /owner-a-wss:\/\/a.example/);
assert.match(text(), /Last heard: Recent/);
+ assert.match(text(), /could not synchronize capability facts/);
const beforeReconnect = pulses;
+ const reportsBeforeReconnect = reports;
await React.act(async () => reconnect());
await settle();
assert.ok(pulses > beforeReconnect, "reconnect reports a fresh pulse");
+ assert.ok(
+ reports > reportsBeforeReconnect,
+ "reconnect retries deferred facts",
+ );
const beforeTimer = pulses;
+ const reportsBeforeTimer = reports;
await React.act(async () => t.mock.timers.tick(60_000));
await settle();
assert.ok(pulses > beforeTimer, "bounded periodic publisher runs");
+ assert.ok(reports > reportsBeforeTimer, "periodic retry survives deferral");
+ assert.equal(publishedReports, 0, "deferred facts are not published");
+ deferReport = false;
+ await React.act(async () => t.mock.timers.tick(60_000));
+ await settle();
+ assert.equal(publishedReports, 1, "later preparation is published");
+ assert.doesNotMatch(text(), /could not synchronize capability facts/);
hold = true;
await React.act(async () => {
void client.refetchQueries({ queryKey: ["desktop-profiles"] });
From d67471c60e4abd819fa3e7a37e9b905f00ef84b9 Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 15:58:40 -0400
Subject: [PATCH 08/51] feat(multiverse): add private immutable Desktop Stop
transport
Keep exact-request retry owner/community private without repeating relay side effects. Exclude request/result ciphertext from search on fresh and upgraded databases.
Signed-off-by: Logan Johnson
---
crates/buzz-core/src/desktop_stop.rs | 220 ++++++++++++++++++
crates/buzz-core/src/kind.rs | 9 +
crates/buzz-core/src/lib.rs | 1 +
crates/buzz-db/src/runtime/migration.rs | 27 ++-
.../src/api/desktop_profile_postgres_tests.rs | 130 ++++++++++-
crates/buzz-relay/src/handlers/event.rs | 32 +++
crates/buzz-relay/src/handlers/ingest.rs | 41 +++-
migrations/0048_desktop_stop_fts.sql | 26 +++
schema/schema.sql | 2 +-
9 files changed, 474 insertions(+), 14 deletions(-)
create mode 100644 crates/buzz-core/src/desktop_stop.rs
create mode 100644 migrations/0048_desktop_stop_fts.sql
diff --git a/crates/buzz-core/src/desktop_stop.rs b/crates/buzz-core/src/desktop_stop.rs
new file mode 100644
index 00000000000..4f431655e99
--- /dev/null
+++ b/crates/buzz-core/src/desktop_stop.rs
@@ -0,0 +1,220 @@
+//! Immutable, owner-to-self Desktop Stop messages. Profiles are not authority.
+use nostr::{nips::nip44, Event, EventBuilder, Keys, Kind, PublicKey, Tag};
+use serde::{Deserialize, Serialize};
+
+use crate::kind::{KIND_DESKTOP_STOP, KIND_DESKTOP_STOP_RESULT};
+
+/// One agent on one Desktop in one community; never a caller-selected process.
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct StopTarget {
+ /// Schema version. Old exact-run commands are not accepted here.
+ pub v: u8,
+ /// Canonical community WebSocket URL.
+ pub community: String,
+ /// Installation coordinate from the private Desktop inventory.
+ pub desktop: String,
+ /// Agent public key. The receiver independently verifies local ownership.
+ pub agent: String,
+}
+
+/// Ordinary Desktop outcome, not a stronger process-termination certificate.
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum StopOutcome {
+ /// Ordinary Stop returned success.
+ Stopped,
+ /// Ordinary Stop returned an error. No automatic retry of the effect.
+ Failed,
+ /// Interrupted, stale or evicted request; never inferred success.
+ Unknown,
+}
+
+/// Correlates exactly one immutable request with its Desktop's result.
+#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct StopResult {
+ /// Original target, not mutable current routing.
+ pub target: StopTarget,
+ /// Signed request event ID.
+ pub request: String,
+ /// No diagnostic paths, credentials or process details on the wire.
+ pub outcome: StopOutcome,
+}
+
+fn hex(value: &str, len: usize) -> bool {
+ value.len() == len
+ && value
+ .bytes()
+ .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
+}
+
+/// Check public shape before storage, without decrypting content.
+pub fn validate_envelope(event: &Event) -> Result<(), &'static str> {
+ let kind = event.kind.as_u16() as u32;
+ let tags: Vec<_> = event.tags.iter().map(|t| t.as_slice()).collect();
+ let result = kind == KIND_DESKTOP_STOP_RESULT;
+ if !matches!(kind, KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT)
+ || !(132..=4096).contains(&event.content.len())
+ || tags.len() != if result { 2 } else { 1 }
+ || tags[0].len() != 2
+ || tags[0][0] != "d"
+ || !hex(&tags[0][1], 32)
+ || (result && (tags[1].len() != 2 || tags[1][0] != "e" || !hex(&tags[1][1], 64)))
+ {
+ return Err("invalid Desktop Stop envelope");
+ }
+ Ok(())
+}
+
+fn sign(value: &T, keys: &Keys, kind: u32, tags: Vec) -> Result {
+ let ciphertext = nip44::encrypt(
+ keys.secret_key(),
+ &keys.public_key(),
+ serde_json::to_string(value).map_err(|e| e.to_string())?,
+ nip44::Version::V2,
+ )
+ .map_err(|e| e.to_string())?;
+ EventBuilder::new(Kind::Custom(kind as u16), ciphertext)
+ .tags(tags)
+ .sign_with_keys(keys)
+ .map_err(|e| e.to_string())
+}
+
+fn read(
+ event: &Event,
+ keys: &Keys,
+ kind: u32,
+) -> Result {
+ validate_envelope(event)?;
+ event
+ .verify()
+ .map_err(|_| "invalid Desktop Stop signature")?;
+ if event.pubkey != keys.public_key() || event.kind.as_u16() as u32 != kind {
+ return Err("foreign Desktop Stop message".into());
+ }
+ let plaintext = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content)
+ .map_err(|_| "Desktop Stop decryption failed")?;
+ serde_json::from_str(&plaintext).map_err(|_| "invalid Desktop Stop payload".into())
+}
+
+impl StopTarget {
+ /// Validate the decrypted target against the captured community.
+ pub fn validate(&self, community: &str) -> Result<(), &'static str> {
+ if self.v != 1
+ || self.community != community
+ || community.is_empty()
+ || community.len() > 512
+ || !hex(&self.desktop, 32)
+ || !hex(&self.agent, 64)
+ || PublicKey::from_hex(&self.agent).is_err()
+ {
+ return Err("invalid Desktop Stop target");
+ }
+ Ok(())
+ }
+
+ /// Produce a new immutable Stop. Transport retries must reuse this event.
+ pub fn sign(&self, keys: &Keys) -> Result {
+ self.validate(&self.community)?;
+ sign(
+ self,
+ keys,
+ KIND_DESKTOP_STOP,
+ vec![Tag::identifier(&self.desktop)],
+ )
+ }
+
+ /// Authenticate, decrypt and bind a Stop to its signed host coordinate.
+ pub fn read(event: &Event, keys: &Keys, community: &str) -> Result {
+ let target: Self = read(event, keys, KIND_DESKTOP_STOP)?;
+ target.validate(community)?;
+ if event.tags.identifier() != Some(target.desktop.as_str()) {
+ return Err("Desktop Stop routing mismatch".into());
+ }
+ Ok(target)
+ }
+}
+
+impl StopResult {
+ /// Sign the saved ordinary Stop result without exposing local diagnostics.
+ pub fn sign(&self, keys: &Keys) -> Result {
+ self.target.validate(&self.target.community)?;
+ if !hex(&self.request, 64) {
+ return Err("invalid Stop request ID".into());
+ }
+ sign(
+ self,
+ keys,
+ KIND_DESKTOP_STOP_RESULT,
+ vec![
+ Tag::identifier(&self.target.desktop),
+ Tag::parse(["e", &self.request]).map_err(|e| e.to_string())?,
+ ],
+ )
+ }
+
+ /// Check all correlation fields against the original authenticated request.
+ pub fn read(
+ event: &Event,
+ keys: &Keys,
+ request: &Event,
+ community: &str,
+ ) -> Result {
+ let target = StopTarget::read(request, keys, community)?;
+ let result: Self = read(event, keys, KIND_DESKTOP_STOP_RESULT)?;
+ if result.target != target
+ || result.request != request.id.to_hex()
+ || event.tags.identifier() != Some(target.desktop.as_str())
+ || event.tags.iter().nth(1).and_then(|t| t.content()) != Some(result.request.as_str())
+ {
+ return Err("Desktop Stop result correlation mismatch".into());
+ }
+ Ok(result)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[test]
+ fn private_immutable_stop_and_exact_result_correlation() {
+ let keys = Keys::generate();
+ let target = StopTarget {
+ v: 1,
+ community: "wss://one.example".into(),
+ desktop: "a".repeat(32),
+ agent: Keys::generate().public_key().to_hex(),
+ };
+ let request = target.sign(&keys).unwrap();
+ assert_eq!(
+ StopTarget::read(&request, &keys, &target.community).unwrap(),
+ target
+ );
+ assert!(!request.content.contains(&target.agent));
+ assert!(StopTarget::read(&request, &Keys::generate(), &target.community).is_err());
+ assert!(StopTarget::read(&request, &keys, "wss://other.example").is_err());
+ let result = StopResult {
+ target: target.clone(),
+ request: request.id.to_hex(),
+ outcome: StopOutcome::Stopped,
+ }
+ .sign(&keys)
+ .unwrap();
+ assert_eq!(
+ StopResult::read(&result, &keys, &request, &target.community)
+ .unwrap()
+ .outcome,
+ StopOutcome::Stopped
+ );
+ let another = target.sign(&keys).unwrap();
+ assert!(StopResult::read(&result, &keys, &another, &target.community).is_err());
+ let mut tampered = request.clone();
+ tampered.content.push('x');
+ assert!(StopTarget::read(&tampered, &keys, &target.community).is_err());
+ for kind in [KIND_DESKTOP_STOP, KIND_DESKTOP_STOP_RESULT] {
+ assert!(crate::kind::AUTHOR_ONLY_KINDS.contains(&kind));
+ assert!(!crate::kind::is_parameterized_replaceable(kind));
+ }
+ }
+}
diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs
index 4d309c3b609..5df974c36cb 100644
--- a/crates/buzz-core/src/kind.rs
+++ b/crates/buzz-core/src/kind.rs
@@ -125,6 +125,11 @@ pub const KIND_DESKTOP_OBSERVATION: u32 = 30181;
/// Owner-private built-in runtime facts per Desktop, not agent readiness.
pub const KIND_DESKTOP_CAPABILITIES: u32 = 30182;
+/// Immutable owner-private Desktop Stop request (not a replaceable profile).
+pub const KIND_DESKTOP_STOP: u32 = 50180;
+/// Owner-private ordinary Desktop Stop outcome, correlated by request event ID.
+pub const KIND_DESKTOP_STOP_RESULT: u32 = 50181;
+
/// Kinds whose stored events are readable only by their author.
///
/// The relay must never reveal the existence, count, tags, content, schedule,
@@ -141,6 +146,8 @@ pub const AUTHOR_ONLY_KINDS: &[u32] = &[
KIND_DESKTOP_PROFILE,
KIND_DESKTOP_OBSERVATION,
KIND_DESKTOP_CAPABILITIES,
+ KIND_DESKTOP_STOP,
+ KIND_DESKTOP_STOP_RESULT,
];
/// Kinds that require a result-level read gate beyond the filter-layer
@@ -673,6 +680,8 @@ pub const ALL_KINDS: &[u32] = &[
KIND_DESKTOP_PROFILE,
KIND_DESKTOP_OBSERVATION,
KIND_DESKTOP_CAPABILITIES,
+ KIND_DESKTOP_STOP,
+ KIND_DESKTOP_STOP_RESULT,
KIND_REPORT,
KIND_PRODUCT_FEEDBACK,
KIND_NIP29_PUT_USER,
diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs
index ee4bb5df789..4b3874be0f0 100644
--- a/crates/buzz-core/src/lib.rs
+++ b/crates/buzz-core/src/lib.rs
@@ -13,6 +13,7 @@ pub mod desktop_capabilities;
pub mod desktop_observation;
/// Owner-private Desktop display profiles.
pub mod desktop_profile;
+pub mod desktop_stop;
/// NIP-AE Agent Engrams — slug grammar, conversation key, d-tag derivation,
/// body parse/serialize, envelope build/validate, head selection.
pub mod engram;
diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs
index 7da5d0d0c68..35261123b61 100644
--- a/crates/buzz-db/src/runtime/migration.rs
+++ b/crates/buzz-db/src/runtime/migration.rs
@@ -702,7 +702,7 @@ mod postgres_tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);
- assert_eq!(migrations.len(), 47);
+ assert_eq!(migrations.len(), 48);
assert_eq!(migrations[0].version, 1);
assert_eq!(&*migrations[0].description, "initial schema");
assert!(migrations[0]
@@ -911,7 +911,7 @@ mod postgres_tests {
assert!(migrations[32].sql.as_str().contains("search_tsv"));
assert!(!migrations[0].sql.as_str().contains("30179"));
assert!(include_str!("../../../../schema/schema.sql").contains(
- "kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200)"
+ "kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200, 50180, 50181)"
));
// Public push-gateway authority is intentionally deployment-global and
@@ -2394,6 +2394,8 @@ mod postgres_tests {
(4_u8, 30_180_i32),
(5_u8, 30_181_i32),
(6_u8, 30_182_i32),
+ (7_u8, 50_180_i32),
+ (8_u8, 50_181_i32),
] {
sqlx::query(
"INSERT INTO events \
@@ -2429,7 +2431,9 @@ mod postgres_tests {
(30_180, true),
(30_181, true),
(30_182, true),
- (30_350, true)
+ (30_350, true),
+ (50_180, true),
+ (50_181, true)
]
);
@@ -2454,7 +2458,9 @@ mod postgres_tests {
(30_180, Some(true)),
(30_181, Some(true)),
(30_182, Some(true)),
- (30_350, None)
+ (30_350, None),
+ (50_180, Some(true)),
+ (50_181, Some(true))
]
);
@@ -2493,6 +2499,15 @@ mod postgres_tests {
"0047 must change brownfield capability FTS"
);
+ run_migrations_through(&pool, 47).await.unwrap();
+ let stop_indexed: i64 = sqlx::query_scalar(
+ "SELECT count(*) FROM events WHERE kind IN (50180, 50181) AND search_tsv IS NOT NULL",
+ )
+ .fetch_one(&pool)
+ .await
+ .unwrap();
+ assert_eq!(stop_indexed, 2, "0048 must change brownfield Stop FTS");
+
run_migrations(&pool)
.await
.expect("apply remaining migrations to populated database");
@@ -2511,7 +2526,9 @@ mod postgres_tests {
(30_180, None),
(30_181, None),
(30_182, None),
- (30_350, None)
+ (30_350, None),
+ (50_180, None),
+ (50_181, None)
]
);
let gin_exists: bool = sqlx::query_scalar(
diff --git a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs
index 80f4619c583..ac35a0dd12e 100644
--- a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs
+++ b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs
@@ -2,7 +2,10 @@
use super::postgres_tests::bridge_handler_test_state;
use super::*;
use axum::{body::Body, http::Request};
-use buzz_core::kind::{KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE};
+use buzz_core::kind::{
+ KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE, KIND_DESKTOP_STOP,
+ KIND_DESKTOP_STOP_RESULT,
+};
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
use serde_json::json;
use tower::ServiceExt;
@@ -82,6 +85,13 @@ async fn desktop_capabilities_authenticated_owner_query_and_private_storage() {
assert_private_desktop(KIND_DESKTOP_CAPABILITIES).await;
}
+#[tokio::test]
+#[ignore = "requires Postgres"]
+async fn desktop_stop_authenticated_owner_query_and_private_storage() {
+ assert_private_desktop(KIND_DESKTOP_STOP).await;
+ assert_private_desktop(KIND_DESKTOP_STOP_RESULT).await;
+}
+
async fn assert_private_desktop(kind: u32) {
let mut state = bridge_handler_test_state()
.await
@@ -103,7 +113,26 @@ async fn assert_private_desktop(kind: u32) {
)
.unwrap();
let id = profile.id.clone();
- let event = if kind == KIND_DESKTOP_PROFILE {
+ let event = if matches!(kind, KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT) {
+ let target = buzz_core::desktop_stop::StopTarget {
+ v: 1,
+ community: format!("wss://{host}"),
+ desktop: id.clone(),
+ agent: Keys::generate().public_key().to_hex(),
+ };
+ let request = target.sign(&owner).unwrap();
+ if kind == KIND_DESKTOP_STOP {
+ request
+ } else {
+ buzz_core::desktop_stop::StopResult {
+ target,
+ request: request.id.to_hex(),
+ outcome: buzz_core::desktop_stop::StopOutcome::Stopped,
+ }
+ .sign(&owner)
+ .unwrap()
+ }
+ } else if kind == KIND_DESKTOP_PROFILE {
profile.sign(&owner).unwrap()
} else if kind == KIND_DESKTOP_CAPABILITIES {
buzz_core::desktop_capabilities::DesktopCapabilities::new(profile, vec![])
@@ -284,3 +313,100 @@ async fn aged_desktop_profile_retries_through_production_ingest_without_resignin
assert_eq!(status, StatusCode::BAD_REQUEST, "{result}");
}
}
+
+/// Same-ID transport retry must redeliver to a currently connected Desktop,
+/// even when the first attempt arrived while it was absent. It cannot re-date,
+/// re-store or expose the request to another owner/community.
+#[tokio::test]
+#[ignore = "requires Postgres and Redis"]
+async fn desktop_stop_retry_redelivers_exact_event_only_to_owner() {
+ use nostr::Filter;
+ use std::sync::atomic::AtomicU8;
+ use tokio::sync::{mpsc, Mutex};
+ use tokio_util::sync::CancellationToken;
+ let mut state = bridge_handler_test_state()
+ .await
+ .expect("test infrastructure");
+ Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).require_auth_token = true;
+ let host = format!("stop-retry-{}.example", uuid::Uuid::new_v4().simple());
+ let community = state
+ .db
+ .ensure_configured_community(&host)
+ .await
+ .unwrap()
+ .id;
+ let owner = Keys::generate();
+ let outsider = Keys::generate();
+ let target = buzz_core::desktop_stop::StopTarget {
+ v: 1,
+ community: format!("wss://{host}"),
+ desktop: uuid::Uuid::new_v4().simple().to_string(),
+ agent: Keys::generate().public_key().to_hex(),
+ };
+ let prepared = target.sign(&owner).unwrap();
+ let event = EventBuilder::new(prepared.kind, &prepared.content)
+ .tags(prepared.tags.iter().cloned())
+ .custom_created_at(Timestamp::from(Timestamp::now().as_secs() - 86_400))
+ .sign_with_keys(&owner)
+ .unwrap();
+ let raw = json!(event);
+ let (status, result) = post(&state, &host, "/events", &owner, raw.clone(), true).await;
+ assert_eq!(status, StatusCode::OK, "{result}");
+ assert_eq!(result["accepted"], true);
+ let mut receivers = vec![];
+ for (who, tenant) in [
+ (&owner, community),
+ (&outsider, community),
+ (
+ &owner,
+ buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()),
+ ),
+ ] {
+ let conn = uuid::Uuid::new_v4();
+ let (tx, rx) = mpsc::channel(16);
+ let (ctrl, _) = mpsc::channel(16);
+ state.conn_manager.register(
+ conn,
+ tx,
+ ctrl,
+ None,
+ CancellationToken::new(),
+ tenant,
+ Arc::new(AtomicU8::new(0)),
+ Arc::new(Mutex::new(std::collections::HashMap::new())),
+ 3,
+ );
+ state
+ .conn_manager
+ .set_authenticated_pubkey(conn, who.public_key().to_bytes().to_vec());
+ state.sub_registry.register_scoped(
+ tenant,
+ conn,
+ "stop".into(),
+ vec![Filter::new().kind(Kind::Custom(KIND_DESKTOP_STOP as u16))],
+ None,
+ );
+ receivers.push(rx);
+ }
+ // Quiesce the asynchronous first dispatch before measuring the retry.
+ tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+ for rx in &mut receivers {
+ drain(rx);
+ }
+ for _ in 0..2 {
+ let (status, result) = post(&state, &host, "/events", &owner, raw.clone(), true).await;
+ assert_eq!(status, StatusCode::OK, "{result}");
+ assert_eq!(result["message"], "duplicate:");
+ let frames = drain(&mut receivers[0]);
+ assert_eq!(frames.len(), 1, "{frames:?}");
+ assert_eq!(frames[0][2], raw);
+ assert!(drain(&mut receivers[1]).is_empty());
+ assert!(drain(&mut receivers[2]).is_empty());
+ }
+ let (status, result) = post(&state, &host, "/events", &outsider, raw, true).await;
+ assert_eq!(status, StatusCode::FORBIDDEN, "{result}");
+ assert!(drain(&mut receivers[0]).is_empty());
+ let (_, rows) = post(&state, &host, "/query", &owner,
+ json!([{"kinds":[KIND_DESKTOP_STOP],"authors":[owner.public_key().to_hex()], "ids":[event.id.to_hex()]}]), true).await;
+ assert_eq!(rows.as_array().unwrap().len(), 1);
+}
diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs
index 832b5678c9d..c4e4d1f86c9 100644
--- a/crates/buzz-relay/src/handlers/event.rs
+++ b/crates/buzz-relay/src/handlers/event.rs
@@ -337,6 +337,32 @@ pub async fn fan_out_pubsub_event(state: &Arc, channel_event: buzz_pub
}
}
+/// Retry delivery of an already stored Stop, without audit/workflow effects.
+/// Admission and durable effect deduplication remain the Desktop's authority.
+pub(crate) async fn redeliver_desktop_stop(
+ tenant: &TenantContext,
+ state: &Arc,
+ event: &nostr::Event,
+) {
+ state.mark_local_event(tenant.community(), &event.id);
+ if let Err(error) = state
+ .pubsub
+ .publish_event(tenant, EventTopic::Global, event)
+ .await
+ {
+ state
+ .local_event_ids
+ .invalidate(&(tenant.community(), event.id.to_bytes()));
+ warn!(event_id = %event.id, %error, "Desktop Stop redelivery to peers failed");
+ }
+ fan_out_event_to_local_subscribers(
+ state,
+ tenant.community(),
+ &StoredEvent::new(event.clone(), None),
+ )
+ .await;
+}
+
/// Schedule post-commit delivery/side effects for a stored event.
///
/// This intentionally returns after only the bounded audit enqueue has completed:
@@ -2220,6 +2246,12 @@ mod tests {
assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_OBSERVATION).await;
}
+ #[tokio::test]
+ async fn desktop_stop_delivers_to_author_only() {
+ assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_STOP).await;
+ assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_STOP_RESULT).await;
+ }
+
#[tokio::test]
async fn desktop_capabilities_delivers_to_author_only() {
assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_CAPABILITIES).await;
diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs
index 9d4e9ad5dc4..112bea02085 100644
--- a/crates/buzz-relay/src/handlers/ingest.rs
+++ b/crates/buzz-relay/src/handlers/ingest.rs
@@ -36,7 +36,10 @@ use buzz_core::kind::{
RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER,
RELAY_ADMIN_SET_WORKSPACE_PROFILE,
};
-use buzz_core::kind::{KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE};
+use buzz_core::kind::{
+ KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE, KIND_DESKTOP_STOP,
+ KIND_DESKTOP_STOP_RESULT,
+};
use buzz_core::tenant::TenantContext;
use buzz_core::verification::verify_event;
use buzz_core::CommunityId;
@@ -437,7 +440,7 @@ fn map_push_accept_error(error: super::push_lease::AcceptError) -> IngestError {
/// Returns `Err` for unknown kinds — the relay rejects them.
fn required_scope_for_kind(kind: u32, event: &Event) -> Result {
match kind {
- KIND_PROFILE | KIND_DESKTOP_PROFILE | KIND_DESKTOP_OBSERVATION | KIND_DESKTOP_CAPABILITIES => Ok(Scope::UsersWrite),
+ KIND_PROFILE | KIND_DESKTOP_PROFILE | KIND_DESKTOP_OBSERVATION | KIND_DESKTOP_CAPABILITIES | KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT => Ok(Scope::UsersWrite),
KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite),
KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM
| KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT
@@ -661,6 +664,8 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
| KIND_DESKTOP_PROFILE
| KIND_DESKTOP_OBSERVATION
| KIND_DESKTOP_CAPABILITIES
+ | KIND_DESKTOP_STOP
+ | KIND_DESKTOP_STOP_RESULT
| KIND_TEAM_CATALOG
// NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope).
// Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag).
@@ -2171,15 +2176,20 @@ pub async fn ingest_event(
result
}
-// Profiles and capability facts are durable records, not freshness signals. A Desktop may
+// Profiles, capabilities and immutable Stop messages are not freshness signals. A Desktop may
// first publish its immutable signed record long after an offline startup.
// Only their past-age bound is waived; future drift and all other admission
// checks still apply. Observation/presence kinds must retain their own window.
fn timestamp_within_ingest_window(kind: u32, event_ts: u64, now: u64) -> bool {
const MAX_TIMESTAMP_DRIFT_SECS: u64 = 900;
event_ts <= now.saturating_add(MAX_TIMESTAMP_DRIFT_SECS)
- && (matches!(kind, KIND_DESKTOP_PROFILE | KIND_DESKTOP_CAPABILITIES)
- || now.saturating_sub(event_ts) <= MAX_TIMESTAMP_DRIFT_SECS)
+ && (matches!(
+ kind,
+ KIND_DESKTOP_PROFILE
+ | KIND_DESKTOP_CAPABILITIES
+ | KIND_DESKTOP_STOP
+ | KIND_DESKTOP_STOP_RESULT
+ ) || now.saturating_sub(event_ts) <= MAX_TIMESTAMP_DRIFT_SECS)
}
async fn ingest_event_inner(
@@ -2794,6 +2804,11 @@ async fn ingest_event_inner(
}
}
+ if matches!(kind_u32, KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT) {
+ buzz_core::desktop_stop::validate_envelope(&event)
+ .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
+ }
+
if kind_u32 == KIND_DESKTOP_CAPABILITIES {
buzz_core::desktop_capabilities::validate_envelope(&event)
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
@@ -3232,6 +3247,12 @@ async fn ingest_event_inner(
};
if !was_inserted {
+ // Stop is a one-shot owned by Desktop, not a replaceable projection.
+ // Explicit transport retry must reach a live receiver even after an ACK
+ // or its result was lost. Never replay history or repeat relay effects.
+ if kind_u32 == KIND_DESKTOP_STOP {
+ super::event::redeliver_desktop_stop(tenant, state, &stored_event.event).await;
+ }
return Ok(IngestResult {
event_id: event_id_hex,
accepted: true,
@@ -3345,6 +3366,8 @@ mod postgres_tests {
for kind in [
KIND_DESKTOP_PROFILE,
KIND_DESKTOP_CAPABILITIES,
+ KIND_DESKTOP_STOP,
+ KIND_DESKTOP_STOP_RESULT,
30181,
KIND_PROFILE,
KIND_EVENT_REMINDER,
@@ -3362,7 +3385,13 @@ mod postgres_tests {
] {
assert_eq!(
timestamp_within_ingest_window(kind, timestamp, now),
- if matches!(kind, KIND_DESKTOP_PROFILE | KIND_DESKTOP_CAPABILITIES) {
+ if matches!(
+ kind,
+ KIND_DESKTOP_PROFILE
+ | KIND_DESKTOP_CAPABILITIES
+ | KIND_DESKTOP_STOP
+ | KIND_DESKTOP_STOP_RESULT
+ ) {
profile
} else {
ordinary
diff --git a/migrations/0048_desktop_stop_fts.sql b/migrations/0048_desktop_stop_fts.sql
new file mode 100644
index 00000000000..98116efbda9
--- /dev/null
+++ b/migrations/0048_desktop_stop_fts.sql
@@ -0,0 +1,26 @@
+-- Owner-private Desktop Stop requests and results must not enter legacy ciphertext search indexes.
+-- Like 0033, this rewrites events under ACCESS EXCLUSIVE; schedule accordingly.
+DO $$
+DECLARE
+ existing_expression TEXT;
+BEGIN
+ SELECT pg_get_expr(d.adbin, d.adrelid)
+ INTO existing_expression
+ FROM pg_attrdef d
+ JOIN pg_attribute a
+ ON a.attrelid = d.adrelid
+ AND a.attnum = d.adnum
+ WHERE d.adrelid = 'events'::regclass
+ AND a.attname = 'search_tsv';
+
+ IF existing_expression IS NULL THEN
+ RAISE EXCEPTION 'events.search_tsv generated expression not found';
+ END IF;
+
+ ALTER TABLE events DROP COLUMN search_tsv;
+ EXECUTE format(
+ 'ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS (CASE WHEN kind IN (50180, 50181) THEN NULL::tsvector ELSE (%s) END) STORED',
+ existing_expression
+ );
+ CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv);
+END $$;
diff --git a/schema/schema.sql b/schema/schema.sql
index d78b3036d97..0a20e940285 100644
--- a/schema/schema.sql
+++ b/schema/schema.sql
@@ -221,7 +221,7 @@ CREATE TABLE events (
-- never matches `@@`.
-- Keep in sync with migrations (final state: 0001 + 0005 + 0014 + 0033).
search_tsv TSVECTOR GENERATED ALWAYS AS (
- CASE WHEN kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector
+ CASE WHEN kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200, 50180, 50181) THEN NULL::tsvector
ELSE to_tsvector('simple', content)
END
) STORED,
From a5b36742cde43280fd6ecf2db8d4aae0d7ed2927 Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 15:59:26 -0400
Subject: [PATCH 09/51] feat(desktop): fence automatic launches after remote
Stop
Bind explicit Start permission before preflight and release it only after child registration. Reuse ordinary platform-specific pair Stop and refuse success for an unscoped live legacy child.
Signed-off-by: Logan Johnson
---
desktop/src-tauri/src/commands/agents.rs | 62 +++++++--
desktop/src-tauri/src/managed_agents/mod.rs | 1 +
.../src/managed_agents/remote_stop.rs | 131 ++++++++++++++++++
.../src-tauri/src/managed_agents/restore.rs | 1 +
.../src-tauri/src/managed_agents/runtime.rs | 10 +-
.../src/managed_agents/runtime/stop.rs | 2 +-
.../src/managed_agents/runtime_commands.rs | 130 +++++++++++------
desktop/src/features/agents/AGENTS.md | 8 ++
desktop/src/features/agents/hooks.ts | 2 +
.../lib/managedAgentControlActions.test.mjs | 10 +-
.../agents/lib/managedAgentControlActions.ts | 7 +-
.../agents/managedAgentRuntimeHooks.ts | 4 +-
.../agents/ui/useManagedAgentActions.ts | 7 +-
.../channels/ui/useMembersSidebarActions.ts | 1 +
.../profile/ui/useAgentLifecycleActions.ts | 4 +-
desktop/src/shared/api/tauriManagedAgents.ts | 10 +-
16 files changed, 326 insertions(+), 64 deletions(-)
create mode 100644 desktop/src-tauri/src/managed_agents/remote_stop.rs
diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs
index 0ad7fd321c5..a3bc56882e2 100644
--- a/desktop/src-tauri/src/commands/agents.rs
+++ b/desktop/src-tauri/src/commands/agents.rs
@@ -160,15 +160,35 @@ pub(super) async fn start_local_agent_pairs_with_preflight(
summarize_from_disk(app, record, &runtimes)
}
-pub(super) async fn start_local_agent_with_preflight(
+enum LocalStartIntent {
+ Create,
+ Explicit,
+ Automatic,
+}
+
+async fn start_local_agent_with_preflight(
app: &AppHandle,
state: &AppState,
pubkey: &str,
- allow_fresh_create_start: bool,
+ intent: LocalStartIntent,
expected_relay_url: Option<&str>,
expected_signer_pubkey: Option<&str>,
replay_floor_unix: Option,
) -> Result {
+ let launch_owner = workspace_owner_hex(state)?;
+ let launch_key = crate::managed_agents::ManagedAgentRuntimeKey::new(
+ pubkey,
+ &relay_ws_url_with_override(state),
+ )?;
+ let resume = if matches!(intent, LocalStartIntent::Explicit) {
+ Some(crate::managed_agents::remote_stop::capture_resume(
+ app,
+ &launch_key,
+ &launch_owner,
+ )?)
+ } else {
+ None
+ };
let record_snapshot = {
let _store_guard = state
.managed_agents_store_lock
@@ -201,7 +221,12 @@ pub(super) async fn start_local_agent_with_preflight(
&personas,
&global,
);
- ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), allow_fresh_create_start).await?;
+ ensure_relay_mesh_for_record(
+ app,
+ mesh_model_id.as_deref(),
+ matches!(intent, LocalStartIntent::Create),
+ )
+ .await?;
// The mesh preflight above is the suspension window Projects callbacks
// capture their scope against: a community switch during that await
@@ -212,15 +237,21 @@ pub(super) async fn start_local_agent_with_preflight(
// point can no longer retarget the spawn (it only changes state this
// call no longer consults).
let workspace_relay_url = crate::relay::bind_expected_relay_scope(
- expected_relay_url,
+ expected_relay_url.or(Some(launch_key.relay_url.as_str())),
crate::relay::relay_ws_url_with_override(state),
)?;
// Bind the active owner after the same final await as the relay. A
// same-relay identity replacement during mesh preflight must not release
// the stale preflight owner to spawn.
- let workspace_owner =
- crate::relay::bind_expected_signer(expected_signer_pubkey, workspace_owner_hex(state)?)?;
+ let workspace_owner = crate::relay::bind_expected_signer(
+ expected_signer_pubkey.or(Some(launch_owner.as_str())),
+ workspace_owner_hex(state)?,
+ )?;
+ let _transition = state
+ .managed_agent_runtime_transition
+ .lock()
+ .map_err(|e| e.to_string())?;
let _store_guard = state
.managed_agents_store_lock
.lock()
@@ -262,6 +293,7 @@ pub(super) async fn start_local_agent_with_preflight(
Some(workspace_owner.as_str()),
&workspace_relay_url,
replay_floor_unix,
+ resume.as_ref(),
)?;
save_managed_agents(app, &records)?;
if let Some(saved_record) = records.iter().find(|r| r.pubkey == pubkey) {
@@ -713,7 +745,16 @@ pub async fn create_managed_agent(
// ── Phase 3b: local spawn (async preflight outside store lock) ───────────
let mut spawn_error = None;
let agent = if input.spawn_after_create && input.backend == BackendKind::Local {
- match start_local_agent_with_preflight(&app, &state, &pubkey, true, None, None, None).await
+ match start_local_agent_with_preflight(
+ &app,
+ &state,
+ &pubkey,
+ LocalStartIntent::Create,
+ None,
+ None,
+ None,
+ )
+ .await
{
Ok(agent) => agent,
Err(error) => {
@@ -824,6 +865,7 @@ pub async fn start_managed_agent(
expected_relay_url: Option,
expected_signer_pubkey: Option,
replay_floor_unix: Option,
+ explicit_start: Option,
app: AppHandle,
state: State<'_, AppState>,
) -> Result {
@@ -920,7 +962,11 @@ pub async fn start_managed_agent(
&app,
&state,
&pubkey,
- false,
+ if explicit_start.unwrap_or(false) {
+ LocalStartIntent::Explicit
+ } else {
+ LocalStartIntent::Automatic
+ },
expected_relay_url.as_deref(),
expected_signer_pubkey.as_deref(),
replay_floor_unix,
diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs
index a66f9c75ba2..e859344e4c7 100644
--- a/desktop/src-tauri/src/managed_agents/mod.rs
+++ b/desktop/src-tauri/src/managed_agents/mod.rs
@@ -32,6 +32,7 @@ mod process_lifecycle;
pub(crate) mod readiness;
pub(crate) mod reconcile;
mod relay_mesh;
+pub(crate) mod remote_stop;
mod repos;
mod restore;
pub mod retention;
diff --git a/desktop/src-tauri/src/managed_agents/remote_stop.rs b/desktop/src-tauri/src/managed_agents/remote_stop.rs
new file mode 100644
index 00000000000..818c7072064
--- /dev/null
+++ b/desktop/src-tauri/src/managed_agents/remote_stop.rs
@@ -0,0 +1,131 @@
+//! Durable no-auto-start fence shared by ordinary Desktop launch paths.
+use super::retention::{open_retention_db, scoped_retention_db_path};
+use super::ManagedAgentRuntimeKey;
+use rusqlite::{Connection, OptionalExtension};
+use tauri::{AppHandle, Manager};
+
+fn schema(conn: &Connection) -> Result<(), String> {
+ conn.execute_batch("CREATE TABLE IF NOT EXISTS desktop_stop_fence (
+ agent TEXT PRIMARY KEY, stamp INTEGER NOT NULL, event_id TEXT NOT NULL, blocked INTEGER NOT NULL);")
+ .map_err(|e| e.to_string())
+}
+
+/// Explicit local Start captures the Stop fence before its asynchronous preflight.
+/// Automatic starts and Restart continuations never receive this permission.
+pub(crate) struct ResumeTicket {
+ previous: Option,
+}
+
+pub(crate) fn capture_resume(
+ app: &AppHandle,
+ key: &ManagedAgentRuntimeKey,
+ owner: &str,
+) -> Result {
+ let conn = connection(app, key, owner)?;
+ schema(&conn)?;
+ let previous = conn
+ .query_row(
+ "SELECT event_id FROM desktop_stop_fence WHERE agent=?1",
+ [&key.pubkey],
+ |r| r.get(0),
+ )
+ .optional()
+ .map_err(|e| e.to_string())?;
+ Ok(ResumeTicket { previous })
+}
+
+fn connection(
+ app: &AppHandle,
+ key: &ManagedAgentRuntimeKey,
+ owner: &str,
+) -> Result {
+ let path =
+ scoped_retention_db_path(&super::managed_agents_base_dir(app)?, &key.relay_url, owner);
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
+ }
+ open_retention_db(&path)
+}
+
+/// Every ordinary spawn passes here, including restore/config/reconcile.
+/// Caller holds the existing transition lock through child registration.
+pub(crate) fn check_launch(
+ app: &AppHandle,
+ key: &ManagedAgentRuntimeKey,
+ owner: Option<&str>,
+ resume: Option<&ResumeTicket>,
+) -> Result<(), String> {
+ let state = app.state::();
+ let current_owner = state.signing_keys()?.public_key().to_hex();
+ if owner != Some(current_owner.as_str()) {
+ return Err("Desktop launch owner changed".into());
+ }
+ let conn = connection(app, key, ¤t_owner)?;
+ schema(&conn)?;
+ let row: Option<(String, bool)> = conn
+ .query_row(
+ "SELECT event_id, blocked FROM desktop_stop_fence WHERE agent=?1",
+ [&key.pubkey],
+ |r| Ok((r.get(0)?, r.get(1)?)),
+ )
+ .optional()
+ .map_err(|e| e.to_string())?;
+ allow_launch(row.as_ref(), resume)
+}
+
+fn allow_launch(row: Option<&(String, bool)>, resume: Option<&ResumeTicket>) -> Result<(), String> {
+ if let Some(ticket) = resume {
+ if ticket.previous.as_ref() != row.map(|(id, _)| id) {
+ return Err("A newer Stop interrupted this Start".into());
+ }
+ } else if row.is_some_and(|(_, blocked)| *blocked) {
+ return Err(
+ "Stopped from another Desktop. Use Start agent to start it again explicitly.".into(),
+ );
+ }
+ Ok(())
+}
+
+/// A failed spawn must not unblock config/restore. Commit only after the child
+/// has its ordinary receipt and tracked handle, still under the transition lock.
+pub(crate) fn finish_resume(
+ app: &AppHandle,
+ key: &ManagedAgentRuntimeKey,
+ owner: Option<&str>,
+ ticket: Option<&ResumeTicket>,
+) -> Result<(), String> {
+ if ticket.is_none() {
+ return Ok(());
+ }
+ let owner = owner.ok_or("Desktop launch owner unavailable")?;
+ check_launch(app, key, Some(owner), ticket)?;
+ connection(app, key, owner)?
+ .execute(
+ "UPDATE desktop_stop_fence SET blocked=0 WHERE agent=?1",
+ [&key.pubkey],
+ )
+ .map_err(|e| e.to_string())?;
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[test]
+ fn launch_fence_requires_explicit_start_and_rejects_delayed_preflight() {
+ let stopped = ("stop-a".to_owned(), true);
+ let resumed = ("stop-a".to_owned(), false);
+ let new_stop = ("stop-b".to_owned(), true);
+ assert!(allow_launch(None, None).is_ok());
+ assert!(allow_launch(Some(&stopped), None).is_err());
+ assert!(allow_launch(Some(&resumed), None).is_ok());
+ let ticket = ResumeTicket {
+ previous: Some("stop-a".to_owned()),
+ };
+ assert!(allow_launch(Some(&stopped), Some(&ticket)).is_ok());
+ assert!(allow_launch(Some(&new_stop), Some(&ticket)).is_err());
+ let before_any_stop = ResumeTicket { previous: None };
+ assert!(allow_launch(Some(&stopped), Some(&before_any_stop)).is_err());
+ assert!(allow_launch(None, Some(&before_any_stop)).is_ok());
+ }
+}
diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs
index 5b79ccac27f..66aa95cba74 100644
--- a/desktop/src-tauri/src/managed_agents/restore.rs
+++ b/desktop/src-tauri/src/managed_agents/restore.rs
@@ -345,6 +345,7 @@ pub async fn restore_managed_agents_on_launch(
true,
owner_hex_ref,
None,
+ None,
)
}) {
Ok(process) => {
diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs
index b8d586b32af..1eec6eee979 100644
--- a/desktop/src-tauri/src/managed_agents/runtime.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime.rs
@@ -31,7 +31,7 @@ mod setup_payload;
use setup_payload::apply_setup_payload_env;
mod stop;
-pub(crate) use stop::managed_agent_runtime_keys;
+pub(crate) use stop::{managed_agent_runtime_keys, stop_managed_agent_pair};
pub use stop::{stop_managed_agent_process, stop_managed_agent_workspace_pair};
mod sweep;
@@ -451,7 +451,10 @@ pub fn spawn_agent_child(
lazy: bool,
owner_hex: Option<&str>,
replay_floor_unix: Option,
+ resume: Option<&super::remote_stop::ResumeTicket>,
) -> Result {
+ let key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), relay_url)?;
+ super::remote_stop::check_launch(app, &key, owner_hex, resume)?;
if let Some(error) = spawn_key_refusal(record) {
return Err(error);
}
@@ -881,6 +884,7 @@ pub fn start_managed_agent_process(
owner_hex: Option<&str>,
workspace_relay: &crate::relay::ScopedWorkspaceRelay,
replay_floor_unix: Option,
+ resume: Option<&super::remote_stop::ResumeTicket>,
) -> Result<(), String> {
let key = bound_runtime_key(record, workspace_relay)?;
if let Some(runtime) = runtimes.get_mut(&key) {
@@ -907,6 +911,7 @@ pub fn start_managed_agent_process(
false,
owner_hex,
replay_floor_unix,
+ resume,
)?;
let now = now_iso();
let receipt = super::ManagedAgentRuntimeReceipt {
@@ -928,7 +933,8 @@ pub fn start_managed_agent_process(
record.last_error = None;
record.last_error_code = None;
- runtimes.insert(key, ManagedAgentPairRuntime::starting(process));
+ runtimes.insert(key.clone(), ManagedAgentPairRuntime::starting(process));
+ super::remote_stop::finish_resume(app, &key, owner_hex, resume)?;
Ok(())
}
diff --git a/desktop/src-tauri/src/managed_agents/runtime/stop.rs b/desktop/src-tauri/src/managed_agents/runtime/stop.rs
index 7b8ded7926d..0c13937ff27 100644
--- a/desktop/src-tauri/src/managed_agents/runtime/stop.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime/stop.rs
@@ -37,7 +37,7 @@ pub(crate) fn managed_agent_runtime_relay_urls(
/// runtime is reinserted so the pair stays visible and stoppable instead of
/// becoming an invisible orphan. Touches no other pair for the agent and
/// does no record-level stop bookkeeping — callers own that.
-fn stop_managed_agent_pair(
+pub(crate) fn stop_managed_agent_pair(
app: &AppHandle,
record: &mut ManagedAgentRecord,
runtimes: &mut HashMap,
diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs
index ba0f91c9f7a..eacb7ba6bf6 100644
--- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs
@@ -3,13 +3,12 @@ use std::sync::atomic::Ordering;
use tauri::{AppHandle, Emitter, Manager};
use super::{
- agent_readiness, append_log_marker, current_instance_id, find_managed_agent_mut,
- load_global_agent_config, load_managed_agents, load_personas, managed_agent_runtime_log_path,
- process_is_running, record_agent_command, resolve_effective_agent_env, save_managed_agents,
- spawn_agent_child, terminate_process, terminate_untracked_pair_runtime,
- write_agent_runtime_receipt, AgentReadiness, BackendKind, ManagedAgentPairRuntime,
- ManagedAgentRuntimeKey, ManagedAgentRuntimeLifecycle, ManagedAgentRuntimeReceipt,
- ManagedAgentRuntimeStatus,
+ agent_readiness, current_instance_id, find_managed_agent_mut, load_global_agent_config,
+ load_managed_agents, load_personas, managed_agent_runtime_log_path, process_is_running,
+ record_agent_command, resolve_effective_agent_env, save_managed_agents, spawn_agent_child,
+ terminate_process, terminate_untracked_pair_runtime, write_agent_runtime_receipt,
+ AgentReadiness, BackendKind, ManagedAgentPairRuntime, ManagedAgentRuntimeKey,
+ ManagedAgentRuntimeLifecycle, ManagedAgentRuntimeReceipt, ManagedAgentRuntimeStatus,
};
use crate::app_state::AppState;
@@ -229,16 +228,24 @@ pub(crate) fn start_managed_agent_runtime_pair_lazy(
relay_url: String,
app: AppHandle,
) -> Result {
- start_pair(pubkey, relay_url, true, None, app)
+ start_pair(pubkey, relay_url, true, None, false, app)
}
#[tauri::command]
pub fn start_managed_agent_runtime(
pubkey: String,
relay_url: String,
+ explicit_start: Option,
app: AppHandle,
) -> Result {
- start_managed_agent_runtime_pair_lazy(pubkey, relay_url, app)
+ start_pair(
+ pubkey,
+ relay_url,
+ true,
+ None,
+ explicit_start.unwrap_or(false),
+ app,
+ )
}
fn start_pair(
@@ -246,6 +253,7 @@ fn start_pair(
relay_url: String,
lazy: bool,
expected_updated_at: Option<&str>,
+ explicit_start: bool,
app: AppHandle,
) -> Result {
let state = app.state::();
@@ -288,8 +296,24 @@ fn start_pair(
.lock()
.ok()
.map(|keys| keys.public_key().to_hex());
- let mut process =
- spawn_agent_child(&app, record, &key.relay_url, lazy, owner.as_deref(), None)?;
+ let resume = if explicit_start {
+ Some(super::remote_stop::capture_resume(
+ &app,
+ &key,
+ owner.as_deref().ok_or("Desktop owner unavailable")?,
+ )?)
+ } else {
+ None
+ };
+ let mut process = spawn_agent_child(
+ &app,
+ record,
+ &key.relay_url,
+ lazy,
+ owner.as_deref(),
+ None,
+ resume.as_ref(),
+ )?;
let now = crate::util::now_iso();
let receipt = ManagedAgentRuntimeReceipt {
key: key.clone(),
@@ -308,6 +332,7 @@ fn start_pair(
record.last_stopped_at = None;
record.last_error = None;
runtimes.insert(key.clone(), ManagedAgentPairRuntime::starting(process));
+ super::remote_stop::finish_resume(&app, &key, owner.as_deref(), resume.as_ref())?;
let status = status_for(&app, record, &key, runtimes.get(&key), None);
drop(runtimes);
save_managed_agents(&app, &records)?;
@@ -326,6 +351,16 @@ pub fn stop_managed_agent_runtime(
.managed_agent_runtime_transition
.lock()
.map_err(|e| e.to_string())?;
+ stop_pair_locked(pubkey, relay_url, app.clone())
+}
+
+// Caller owns managed_agent_runtime_transition for the whole admission/effect.
+pub(crate) fn stop_pair_locked(
+ pubkey: String,
+ relay_url: String,
+ app: AppHandle,
+) -> Result {
+ let state = app.state::();
let _store = state
.managed_agents_store_lock
.lock()
@@ -337,42 +372,27 @@ pub fn stop_managed_agent_runtime(
.managed_agent_processes
.lock()
.map_err(|e| e.to_string())?;
- if let Some(mut runtime) = runtimes.remove(&key) {
- let stop_result = if process_is_running(runtime.child.id()) {
- terminate_process(runtime.child.id())
- } else {
- Ok(())
- }
- .and_then(|()| runtime.child.wait().map_err(|e| e.to_string()));
- match stop_result {
- Ok(status) => {
- record.last_exit_code = status.code();
- let _ = append_log_marker(&runtime.log_path, "=== stopped pair runtime ===");
- }
- Err(error) => {
- // Keep failed teardown visible/manageable instead of
- // orphaning it: the child stays tracked and the receipt
- // stays on disk until a stop actually succeeds.
- runtimes.insert(key, runtime);
- return Err(error);
- }
- }
+ if runtimes.contains_key(&key) {
+ // Use ordinary Desktop Stop, including its platform-specific child/job
+ // ownership. Remote control must not grow a second teardown contract.
+ super::stop_managed_agent_pair(&app, record, &mut runtimes, &key)?;
} else {
- // No runtime is tracked at this key, but a valid prior-session
- // receipt may still point at a live child (e.g. the crash-recovery
- // window for a non-auto-start agent). Terminate that orphan before
- // erasing its receipt — otherwise this "stop" leaves the harness
- // running yet deletes the one artifact sweeps and
- // terminate_untracked_pair_runtime use to find it, and a follow-up
- // start would spawn a duplicate harness for the same pair. On
- // failure the receipt stays on disk (terminate_untracked_pair_runtime
- // only removes it after the child exits), mirroring the tracked
- // path's keep-until-success invariant.
terminate_untracked_pair_runtime(&app, &key)?;
}
+ // Old scalar records have no community-bound receipt. Do not erase a live
+ // child or claim success for it when this request cannot establish scope.
+ reject_unscoped_live_child(
+ record.runtime_pid.filter(|pid| process_is_running(*pid)),
+ runtimes.values().map(|runtime| runtime.child.id()),
+ )?;
super::remove_agent_runtime_receipt(&app, &key);
state.clear_agent_session_cache(&key);
- record.runtime_pid = None;
+ if record
+ .runtime_pid
+ .is_some_and(|pid| !process_is_running(pid))
+ {
+ record.runtime_pid = None;
+ }
record.updated_at = crate::util::now_iso();
record.last_stopped_at = Some(record.updated_at.clone());
let status = status_for(&app, record, &key, None, None);
@@ -382,6 +402,16 @@ pub fn stop_managed_agent_runtime(
Ok(status)
}
+fn reject_unscoped_live_child(
+ live_pid: Option,
+ tracked: impl Iterator- ,
+) -> Result<(), String> {
+ if live_pid.is_some_and(|pid| !tracked.into_iter().any(|other| other == pid)) {
+ return Err("Legacy runtime is not bound to this community; use local Desktop Stop".into());
+ }
+ Ok(())
+}
+
#[tauri::command]
pub fn restart_managed_agent_runtime(
pubkey: String,
@@ -389,7 +419,7 @@ pub fn restart_managed_agent_runtime(
app: AppHandle,
) -> Result
{
stop_managed_agent_runtime(pubkey.clone(), relay_url.clone(), app.clone())?;
- start_pair(pubkey, relay_url, true, None, app)
+ start_pair(pubkey, relay_url, true, None, false, app)
}
/// Probe whether this agent can operate on `requested_relay_url`.
@@ -513,6 +543,7 @@ pub async fn reconcile_managed_agent_runtimes(
key.relay_url.clone(),
true,
Some(&record.updated_at),
+ false,
app.clone(),
) {
Ok(mut status) => {
@@ -732,3 +763,16 @@ mod tests {
assert!(observer_lifecycle_key(&ready_with_error.pubkey, &ready_with_error).is_err());
}
}
+
+#[cfg(test)]
+mod stop_scope_tests {
+ use super::reject_unscoped_live_child;
+
+ #[test]
+ fn live_legacy_child_cannot_be_erased_or_reported_stopped() {
+ assert!(reject_unscoped_live_child(Some(12), [].into_iter()).is_err());
+ assert!(reject_unscoped_live_child(Some(12), [13].into_iter()).is_err());
+ assert!(reject_unscoped_live_child(Some(12), [12].into_iter()).is_ok());
+ assert!(reject_unscoped_live_child(None, [13].into_iter()).is_ok());
+ }
+}
diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md
index dfb9c0ed494..1af98978be4 100644
--- a/desktop/src/features/agents/AGENTS.md
+++ b/desktop/src/features/agents/AGENTS.md
@@ -308,6 +308,14 @@ with a TypeScript lookup table or an id comparison in a component.
17. **Databricks model discovery has one shared catalog authority.** Desktop and ACP call the shared `buzz-agent` discovery library; Desktop passes the effective merged `DATABRICKS_MODEL_FILTER` explicitly, and the library applies it to raw workspace endpoint IDs and Unity Catalog model-service FQNs after the additive union. A successful filtered-empty catalog is authoritative: it stays empty, disables switching, and never falls through to configured or known-model fallback. UC FQNs are catalog data and always use the MLflow Chat Completions route, regardless of family-looking text in their components. Global Defaults preserves the discovered model ID as the selected value while its closed trigger renders the provider-scoped display label; do not force the raw persisted ID over that label.
+## Desktop Stop launch fence
+
+All local spawn paths consume the durable Stop fence at the shared native
+spawn boundary. Only a deliberate **Start agent** action can supersede that
+fence; config/restore/reconcile and Restart continuations cannot. Explicit Start
+captures its fence before preflight and fails if a newer Stop arrives. Fence
+release happens only after the new child has its receipt and tracked handle.
+
## Channel-only runtime controls
Desktop observer controls identify a channel, not a thread session. The harness
diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts
index ec1ccd262e8..8f2fe3ca1c0 100644
--- a/desktop/src/features/agents/hooks.ts
+++ b/desktop/src/features/agents/hooks.ts
@@ -595,6 +595,7 @@ export function useStartManagedAgentMutation() {
expectedRelayUrl?: string;
expectedSignerPubkey?: string;
replayFloorUnix?: number;
+ explicitStart?: boolean;
},
) =>
typeof input === "string"
@@ -603,6 +604,7 @@ export function useStartManagedAgentMutation() {
expectedRelayUrl: input.expectedRelayUrl,
expectedSignerPubkey: input.expectedSignerPubkey,
replayFloorUnix: input.replayFloorUnix,
+ explicitStart: input.explicitStart,
}),
onSuccess: (updated) => {
queryClient.setQueryData(
diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs
index e6926b36d2e..e2efef08c33 100644
--- a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs
+++ b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs
@@ -56,7 +56,10 @@ test("relay-mesh agents delegate start to the backend preflight", async () => {
calledWith = pubkey;
},
});
- assert.equal(calledWith, meshAgent.pubkey);
+ assert.deepEqual(calledWith, {
+ pubkey: meshAgent.pubkey,
+ explicitStart: true,
+ });
// Backend preflight failures (e.g. no live serve target) propagate as-is.
await assert.rejects(
@@ -78,7 +81,10 @@ test("ordinary local agents still start normally", async () => {
calledWith = pubkey;
},
});
- assert.equal(calledWith, "deadbeef".repeat(8));
+ assert.deepEqual(calledWith, {
+ pubkey: "deadbeef".repeat(8),
+ explicitStart: true,
+ });
});
// --- respawnManagedAgentWithRules: stop→clear→start boundary tests -----------
diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts
index aaf10075e0d..d0fea69a062 100644
--- a/desktop/src/features/agents/lib/managedAgentControlActions.ts
+++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts
@@ -8,7 +8,10 @@ type DeleteManagedAgentInput = {
forceRemoteDelete?: boolean;
};
-type StartManagedAgent = (pubkey: string) => Promise;
+export type ManagedAgentStartInput =
+ | string
+ | { pubkey: string; explicitStart: true };
+type StartManagedAgent = (input: ManagedAgentStartInput) => Promise;
type StopManagedAgent = (pubkey: string) => Promise;
type DeleteManagedAgent = (input: DeleteManagedAgentInput) => Promise;
@@ -82,7 +85,7 @@ export async function startManagedAgentWithRules({
// Relay-mesh agents are no longer blocked here: the backend start preflight
// (ensure_relay_mesh_for_record) re-resolves a live serve target and dials
// it, failing with an actionable error when no peer serves the model.
- await startManagedAgent(agent.pubkey);
+ await startManagedAgent({ pubkey: agent.pubkey, explicitStart: true });
}
export async function respawnManagedAgentWithRules({
diff --git a/desktop/src/features/agents/managedAgentRuntimeHooks.ts b/desktop/src/features/agents/managedAgentRuntimeHooks.ts
index 96a3abc78d4..21dfe9a94fe 100644
--- a/desktop/src/features/agents/managedAgentRuntimeHooks.ts
+++ b/desktop/src/features/agents/managedAgentRuntimeHooks.ts
@@ -185,10 +185,12 @@ export function useManagedAgentRuntimeAction() {
action,
pubkey,
relayUrl,
+ explicitStart = false,
}: {
action: "start" | "stop" | "restart";
pubkey: string;
relayUrl: string;
+ explicitStart?: boolean;
}) => {
if (action === "stop") return stopManagedAgentRuntime(pubkey, relayUrl);
if (action === "restart") {
@@ -200,7 +202,7 @@ export function useManagedAgentRuntimeAction() {
startManagedAgentRuntime,
);
}
- return startManagedAgentRuntime(pubkey, relayUrl);
+ return startManagedAgentRuntime(pubkey, relayUrl, explicitStart);
},
onSuccess: (runtime, { action }) => {
// For stop-only: clear stale working badges immediately. The restart
diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts
index 9c06044c5a9..5f5cb277457 100644
--- a/desktop/src/features/agents/ui/useManagedAgentActions.ts
+++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts
@@ -433,10 +433,11 @@ export function useManagedAgentActions() {
stopMutation.isPending ||
startOnLaunchMutation.isPending ||
deleteMutation.isPending;
- const startingAgentPubkey =
- startMutation.isPending && typeof startMutation.variables === "string"
+ const startingAgentPubkey = startMutation.isPending
+ ? typeof startMutation.variables === "string"
? startMutation.variables
- : null;
+ : (startMutation.variables?.pubkey ?? null)
+ : null;
return {
relayAgentsQuery,
diff --git a/desktop/src/features/channels/ui/useMembersSidebarActions.ts b/desktop/src/features/channels/ui/useMembersSidebarActions.ts
index ced4836d6b4..63a64899b61 100644
--- a/desktop/src/features/channels/ui/useMembersSidebarActions.ts
+++ b/desktop/src/features/channels/ui/useMembersSidebarActions.ts
@@ -176,6 +176,7 @@ export function useMembersSidebarActions({
action,
pubkey: agent.pubkey,
relayUrl,
+ explicitStart: action === "start",
});
setActionNoticeMessage(
action === "stop"
diff --git a/desktop/src/features/profile/ui/useAgentLifecycleActions.ts b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts
index 745df71768d..0b2e79ab6e5 100644
--- a/desktop/src/features/profile/ui/useAgentLifecycleActions.ts
+++ b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts
@@ -28,7 +28,9 @@ export function useAgentLifecycleActions({
channels: readonly Channel[] | undefined;
managedAgent: ManagedAgent | undefined;
relayAgents: readonly RelayAgent[] | undefined;
- startManagedAgent: (pubkey: string) => Promise;
+ startManagedAgent: (
+ input: import("@/features/agents/lib/managedAgentControlActions").ManagedAgentStartInput,
+ ) => Promise;
stopManagedAgent: (pubkey: string) => Promise;
}) {
const handleAgentPrimaryAction = React.useCallback(async () => {
diff --git a/desktop/src/shared/api/tauriManagedAgents.ts b/desktop/src/shared/api/tauriManagedAgents.ts
index ed7e053f259..3d33e925c13 100644
--- a/desktop/src/shared/api/tauriManagedAgents.ts
+++ b/desktop/src/shared/api/tauriManagedAgents.ts
@@ -24,6 +24,8 @@ export async function startManagedAgent(
* long the spawn takes. Local spawns receive it as process env; provider
* deploys carry it in the payload's launch.policy_env. */
replayFloorUnix?: number;
+ /** Only a deliberate Start button may supersede a remote Stop. */
+ explicitStart?: boolean;
},
): Promise {
const response = await invokeTauri("start_managed_agent", {
@@ -31,6 +33,7 @@ export async function startManagedAgent(
expectedRelayUrl: options?.expectedRelayUrl ?? null,
expectedSignerPubkey: options?.expectedSignerPubkey ?? null,
replayFloorUnix: options?.replayFloorUnix ?? null,
+ explicitStart: options?.explicitStart ?? false,
});
return fromRawManagedAgent(response);
}
@@ -81,8 +84,13 @@ export async function listManagedAgentRuntimes(): Promise<
export async function startManagedAgentRuntime(
pubkey: string,
relayUrl: string,
+ explicitStart = false,
): Promise {
- return invokeTauri("start_managed_agent_runtime", { pubkey, relayUrl });
+ return invokeTauri("start_managed_agent_runtime", {
+ pubkey,
+ relayUrl,
+ explicitStart,
+ });
}
export async function stopManagedAgentRuntime(
From 18a0d17070d24988262f7f02c6cd49b6cdb5666d Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 15:59:52 -0400
Subject: [PATCH 10/51] feat(desktop): authenticate and durably consume remote
Stop
Bind owner delegation, installation, agent and community before ordinary Stop. Persist admission before effects and retain exact signed outcomes for retries across reopening and bounded eviction.
Signed-off-by: Logan Johnson
---
.../src-tauri/src/commands/desktop_stop.rs | 145 +++++++++
desktop/src-tauri/src/commands/mod.rs | 2 +
desktop/src-tauri/src/lib.rs | 3 +
.../src/managed_agents/remote_stop.rs | 299 +++++++++++++++++-
desktop/src/features/agents/AGENTS.md | 11 +-
5 files changed, 453 insertions(+), 7 deletions(-)
create mode 100644 desktop/src-tauri/src/commands/desktop_stop.rs
diff --git a/desktop/src-tauri/src/commands/desktop_stop.rs b/desktop/src-tauri/src/commands/desktop_stop.rs
new file mode 100644
index 00000000000..11ab7fe4e14
--- /dev/null
+++ b/desktop/src-tauri/src/commands/desktop_stop.rs
@@ -0,0 +1,145 @@
+//! Native owner/host validation and ordinary Stop; no keys cross IPC.
+use super::desktop_profiles::{prepare, scope};
+use crate::{
+ app_state::AppState,
+ managed_agents::{self, remote_stop, retention::open_retention_db},
+};
+use buzz_core_pkg::{
+ desktop_profile::DesktopProfile,
+ desktop_stop::{StopOutcome, StopResult, StopTarget},
+};
+use nostr::{Event, JsonUtil, PublicKey};
+use serde_json::{json, Value};
+use tauri::{AppHandle, Manager};
+
+fn local_id(
+ conn: &mut rusqlite::Connection,
+ scope: &managed_agents::retention::RetentionScope,
+) -> Result {
+ let saved = prepare(conn, scope)?;
+ let event: Event = serde_json::from_value(saved["event"].clone()).map_err(|e| e.to_string())?;
+ Ok(DesktopProfile::read(
+ &event,
+ &scope.owner_keys,
+ scope.relay_url.trim_end_matches('/'),
+ )?
+ .id)
+}
+
+/// Persist exact signed bytes before the UI sends a new Stop. No boot replay.
+#[tauri::command]
+pub fn prepare_desktop_stop(
+ app: AppHandle,
+ owner: String,
+ community: String,
+ desktop: String,
+ agent: String,
+) -> Result {
+ let state = app.state::();
+ let scope = scope(&app, &state, &owner, &community)?;
+ let event = StopTarget {
+ v: 1,
+ community,
+ desktop,
+ agent,
+ }
+ .sign(&scope.owner_keys)?;
+ let conn = open_retention_db(&scope.db_path)?;
+ // Only the current UI operation needs retry bytes; retained receiver fences
+ // and results are separate. Nothing automatically drains this slot.
+ conn.execute_batch("CREATE TABLE IF NOT EXISTS desktop_stop_outgoing (slot INTEGER PRIMARY KEY CHECK(slot=1), raw TEXT NOT NULL)")
+ .map_err(|e| e.to_string())?;
+ conn.execute("INSERT INTO desktop_stop_outgoing VALUES (1, ?1) ON CONFLICT(slot) DO UPDATE SET raw=excluded.raw", [event.as_json()])
+ .map_err(|e| e.to_string())?;
+ Ok(event)
+}
+
+/// Called only for live owner-private delivery. Reopening never fetches commands.
+#[tauri::command]
+pub async fn receive_desktop_stop(
+ app: AppHandle,
+ owner: String,
+ community: String,
+ event: Event,
+) -> Result, String> {
+ tokio::task::spawn_blocking(move || {
+ let state = app.state::();
+ let _transition = state
+ .managed_agent_runtime_transition
+ .lock()
+ .map_err(|e| e.to_string())?;
+ let scope = scope(&app, &state, &owner, &community)?;
+ let target = StopTarget::read(&event, &scope.owner_keys, &community)?;
+ let mut conn = open_retention_db(&scope.db_path)?;
+ let desktop = local_id(&mut conn, &scope)?;
+ if desktop != target.desktop {
+ return Ok(None);
+ }
+ // Local possession alone is insufficient after an account switch:
+ // verify the stored agent's owner delegation against the request author.
+ let owned = {
+ let _store = state
+ .managed_agents_store_lock
+ .lock()
+ .map_err(|e| e.to_string())?;
+ let records = managed_agents::load_managed_agents(&app)?;
+ records
+ .iter()
+ .find(|r| r.pubkey == target.agent)
+ .is_some_and(|r| {
+ r.backend == managed_agents::BackendKind::Local
+ && r.auth_tag
+ .as_deref()
+ .and_then(|tag| {
+ let key = PublicKey::from_hex(&target.agent).ok()?;
+ buzz_sdk_pkg::nip_oa::verify_auth_tag(tag, &key).ok()
+ })
+ .is_some_and(|key| key.to_hex() == owner)
+ })
+ };
+ remote_stop::receive(
+ &mut conn,
+ &event,
+ &scope.owner_keys,
+ &community,
+ &desktop,
+ owned,
+ |target| {
+ managed_agents::stop_pair_locked(
+ target.agent.clone(),
+ community.clone(),
+ app.clone(),
+ )
+ .map(|_| ())
+ },
+ )
+ })
+ .await
+ .map_err(|e| format!("Desktop Stop task failed: {e}"))?
+}
+
+/// Result queries never dispatch/replay a request. Missing means Unknown.
+#[tauri::command]
+pub fn read_desktop_stop_results(
+ app: AppHandle,
+ owner: String,
+ community: String,
+ request: Event,
+ events: Vec,
+) -> Result {
+ let state = app.state::();
+ let scope = scope(&app, &state, &owner, &community)?;
+ StopTarget::read(&request, &scope.owner_keys, &community)?;
+ if events.len() > 16 {
+ return Err("too many Desktop Stop results".into());
+ }
+ let mut outcome = StopOutcome::Unknown;
+ for event in events {
+ let result = StopResult::read(&event, &scope.owner_keys, &request, &community)?;
+ // Persisted terminal result beats a later Unknown after bounded eviction.
+ if result.outcome != StopOutcome::Unknown {
+ outcome = result.outcome;
+ }
+ }
+ Ok(json!(outcome))
+}
diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs
index 0cb37bf7e02..5a5c2566ac1 100644
--- a/desktop/src-tauri/src/commands/mod.rs
+++ b/desktop/src-tauri/src/commands/mod.rs
@@ -20,6 +20,7 @@ mod channels;
mod clipboard;
mod desktop_capabilities;
mod desktop_profiles;
+mod desktop_stop;
mod dms;
mod engrams;
mod export_util;
@@ -96,6 +97,7 @@ pub use channels::*;
pub use clipboard::*;
pub use desktop_capabilities::*;
pub use desktop_profiles::*;
+pub use desktop_stop::*;
pub use dms::*;
pub use engrams::*;
pub use global_agent_config::*;
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index f794243354d..2eb43d10670 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -553,6 +553,9 @@ pub fn run() {
title_bar_double_click,
get_identity,
prepare_desktop_profile,
+ prepare_desktop_stop,
+ receive_desktop_stop,
+ read_desktop_stop_results,
read_desktop_profiles,
prepare_desktop_observation,
read_desktop_observations,
diff --git a/desktop/src-tauri/src/managed_agents/remote_stop.rs b/desktop/src-tauri/src/managed_agents/remote_stop.rs
index 818c7072064..93e3e9b5adc 100644
--- a/desktop/src-tauri/src/managed_agents/remote_stop.rs
+++ b/desktop/src-tauri/src/managed_agents/remote_stop.rs
@@ -1,15 +1,120 @@
-//! Durable no-auto-start fence shared by ordinary Desktop launch paths.
+//! Durable Stop admission. Compact outcomes never compact the per-agent fence.
+use buzz_core_pkg::desktop_stop::{StopOutcome, StopResult, StopTarget};
+use nostr::{Event, JsonUtil, Keys};
+use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior};
+use tauri::{AppHandle, Manager};
+
use super::retention::{open_retention_db, scoped_retention_db_path};
use super::ManagedAgentRuntimeKey;
-use rusqlite::{Connection, OptionalExtension};
-use tauri::{AppHandle, Manager};
+
+const HISTORY_LIMIT: i64 = 256;
fn schema(conn: &Connection) -> Result<(), String> {
conn.execute_batch("CREATE TABLE IF NOT EXISTS desktop_stop_fence (
- agent TEXT PRIMARY KEY, stamp INTEGER NOT NULL, event_id TEXT NOT NULL, blocked INTEGER NOT NULL);")
+ agent TEXT PRIMARY KEY, stamp INTEGER NOT NULL, event_id TEXT NOT NULL, blocked INTEGER NOT NULL);
+ CREATE TABLE IF NOT EXISTS desktop_stop_results (
+ id TEXT PRIMARY KEY, raw TEXT NOT NULL);")
.map_err(|e| e.to_string())
}
+/// Persist admission before effect; duplicates/interruption never repeat Stop.
+pub(crate) fn admit(
+ conn: &mut Connection,
+ request: &Event,
+ target: &StopTarget,
+) -> Result {
+ schema(conn)?;
+ let tx = conn
+ .transaction_with_behavior(TransactionBehavior::Immediate)
+ .map_err(|e| e.to_string())?;
+ let previous: Option<(u64, String)> = tx
+ .query_row(
+ "SELECT stamp, event_id FROM desktop_stop_fence WHERE agent = ?1",
+ [&target.agent],
+ |r| Ok((r.get(0)?, r.get(1)?)),
+ )
+ .optional()
+ .map_err(|e| e.to_string())?;
+ let id = request.id.to_hex();
+ let stamp = request.created_at.as_secs();
+ if previous.is_some_and(|(time, key)| time > stamp || (time == stamp && key <= id)) {
+ return Ok(false);
+ }
+ tx.execute("INSERT INTO desktop_stop_fence VALUES (?1, ?2, ?3, 1)
+ ON CONFLICT(agent) DO UPDATE SET stamp=excluded.stamp, event_id=excluded.event_id, blocked=1",
+ params![target.agent, stamp, id]).map_err(|e| e.to_string())?;
+ tx.commit().map_err(|e| e.to_string())?;
+ Ok(true)
+}
+
+pub(crate) fn saved_result(conn: &Connection, id: &str) -> Result, String> {
+ schema(conn)?;
+ conn.query_row(
+ "SELECT raw FROM desktop_stop_results WHERE id=?1",
+ [id],
+ |r| r.get(0),
+ )
+ .optional()
+ .map_err(|e| e.to_string())
+}
+
+pub(crate) fn save_result(conn: &mut Connection, id: &str, raw: &str) -> Result<(), String> {
+ schema(conn)?;
+ let tx = conn
+ .transaction_with_behavior(TransactionBehavior::Immediate)
+ .map_err(|e| e.to_string())?;
+ tx.execute(
+ "INSERT OR IGNORE INTO desktop_stop_results VALUES (?1, ?2)",
+ params![id, raw],
+ )
+ .map_err(|e| e.to_string())?;
+ tx.execute(
+ "DELETE FROM desktop_stop_results WHERE rowid NOT IN
+ (SELECT rowid FROM desktop_stop_results ORDER BY rowid DESC LIMIT ?1)",
+ [HISTORY_LIMIT],
+ )
+ .map_err(|e| e.to_string())?;
+ tx.commit().map_err(|e| e.to_string())
+}
+
+/// Authenticate and durably consume a live request before invoking ordinary Stop.
+/// The caller holds the runtime transition lock across this entire operation.
+pub(crate) fn receive(
+ conn: &mut Connection,
+ request: &Event,
+ keys: &Keys,
+ community: &str,
+ desktop: &str,
+ owned: bool,
+ stop: impl FnOnce(&StopTarget) -> Result<(), String>,
+) -> Result , String> {
+ let target = StopTarget::read(request, keys, community)?;
+ if target.desktop != desktop {
+ return Ok(None);
+ }
+ let id = request.id.to_hex();
+ if let Some(raw) = saved_result(conn, &id)? {
+ let result = Event::from_json(raw).map_err(|e| e.to_string())?;
+ StopResult::read(&result, keys, request, community)?;
+ return Ok(Some(result));
+ }
+ let outcome = if !owned {
+ StopOutcome::Failed
+ } else if admit(conn, request, &target)? {
+ outcome(stop(&target))
+ } else {
+ StopOutcome::Unknown
+ };
+ let result = StopResult {
+ target,
+ request: id.clone(),
+ outcome,
+ }
+ .sign(keys)?;
+ save_result(conn, &id, &result.as_json())?;
+ Ok(Some(result))
+}
+
/// Explicit local Start captures the Stop fence before its asynchronous preflight.
/// Automatic starts and Restart continuations never receive this permission.
pub(crate) struct ResumeTicket {
@@ -108,9 +213,27 @@ pub(crate) fn finish_resume(
Ok(())
}
+/// Expose outcomes without mistaking a missing record for success.
+pub(crate) fn outcome(stopped: Result<(), String>) -> StopOutcome {
+ if stopped.is_ok() {
+ StopOutcome::Stopped
+ } else {
+ StopOutcome::Failed
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
+ use nostr::{EventBuilder, Keys, Timestamp};
+ fn request(keys: &Keys, target: &StopTarget, time: u64) -> Event {
+ let e = target.sign(keys).unwrap();
+ EventBuilder::new(e.kind, e.content)
+ .tags(e.tags.to_vec())
+ .custom_created_at(Timestamp::from(time))
+ .sign_with_keys(keys)
+ .unwrap()
+ }
#[test]
fn launch_fence_requires_explicit_start_and_rejects_delayed_preflight() {
let stopped = ("stop-a".to_owned(), true);
@@ -128,4 +251,172 @@ mod tests {
assert!(allow_launch(Some(&stopped), Some(&before_any_stop)).is_err());
assert!(allow_launch(None, Some(&before_any_stop)).is_ok());
}
+
+ #[test]
+ fn saved_result_is_immutable_and_duplicate_after_interruption_is_unknown() {
+ let mut conn = Connection::open_in_memory().unwrap();
+ let keys = Keys::generate();
+ let target = StopTarget {
+ v: 1,
+ community: "wss://one.example".into(),
+ desktop: "a".repeat(32),
+ agent: Keys::generate().public_key().to_hex(),
+ };
+ let event = request(&keys, &target, 100);
+ assert!(admit(&mut conn, &event, &target).unwrap());
+ // A crash between admission and recording an outcome never reexecutes.
+ assert!(saved_result(&conn, &event.id.to_hex()).unwrap().is_none());
+ assert!(!admit(&mut conn, &event, &target).unwrap());
+ save_result(&mut conn, &event.id.to_hex(), "original bytes").unwrap();
+ save_result(&mut conn, &event.id.to_hex(), "replacement").unwrap();
+ assert_eq!(
+ saved_result(&conn, &event.id.to_hex()).unwrap().as_deref(),
+ Some("original bytes")
+ );
+ assert_eq!(
+ outcome(Err("ordinary Stop failed".into())),
+ StopOutcome::Failed
+ );
+ assert_eq!(outcome(Ok(())), StopOutcome::Stopped);
+ }
+
+ #[test]
+ fn receiver_authenticates_routes_and_returns_exact_saved_result_without_effect() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("receiver.db");
+ let mut conn = open_retention_db(&path).unwrap();
+ let keys = Keys::generate();
+ let target = StopTarget {
+ v: 1,
+ community: "wss://one.example".into(),
+ desktop: "a".repeat(32),
+ agent: Keys::generate().public_key().to_hex(),
+ };
+ let event = request(&keys, &target, 100);
+ let no_effect = |_: &StopTarget| panic!("must not invoke ordinary Stop");
+ let foreign = Keys::generate();
+ for (signer, community, host, rejected) in [
+ (
+ &foreign,
+ target.community.as_str(),
+ target.desktop.as_str(),
+ true,
+ ),
+ (&keys, "wss://other.example", target.desktop.as_str(), true),
+ (
+ &keys,
+ target.community.as_str(),
+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ false,
+ ),
+ ] {
+ let result = receive(&mut conn, &event, signer, community, host, true, no_effect);
+ if rejected {
+ assert!(result.is_err());
+ } else {
+ assert!(result.unwrap().is_none());
+ }
+ }
+ let receive_owned = |conn: &mut Connection, event: &Event, owned, stop| {
+ receive(
+ conn,
+ event,
+ &keys,
+ &target.community,
+ &target.desktop,
+ owned,
+ stop,
+ )
+ .unwrap()
+ .unwrap()
+ };
+ let fail: fn(&StopTarget) -> Result<(), String> = |_| Err("ordinary Stop error".into());
+ let no_effect: fn(&StopTarget) -> Result<(), String> = no_effect;
+ // The first effect succeeds. The reopened retry must return its exact
+ // signed bytes without invoking the callback at all.
+ let mut effects = 0;
+ let result = receive(
+ &mut conn,
+ &event,
+ &keys,
+ &target.community,
+ &target.desktop,
+ true,
+ |actual| {
+ assert_eq!(actual, &target);
+ effects += 1;
+ Ok(())
+ },
+ )
+ .unwrap()
+ .unwrap();
+ assert_eq!(effects, 1);
+ let assert_outcome = |result: &Event, request: &Event, expected| {
+ assert_eq!(
+ StopResult::read(result, &keys, request, &target.community)
+ .unwrap()
+ .outcome,
+ expected
+ );
+ };
+ assert_outcome(&result, &event, StopOutcome::Stopped);
+ drop(conn);
+ let mut conn = open_retention_db(&path).unwrap();
+ let duplicate = receive_owned(&mut conn, &event, true, no_effect);
+ assert_eq!(result.as_json(), duplicate.as_json());
+ let next = request(&keys, &target, 101);
+ let failed = receive_owned(&mut conn, &next, true, fail);
+ assert_outcome(&failed, &next, StopOutcome::Failed);
+ assert_eq!(
+ failed.as_json(),
+ receive_owned(&mut conn, &next, true, no_effect).as_json()
+ );
+ let unowned = request(&keys, &target, 102);
+ let denied = receive_owned(&mut conn, &unowned, false, no_effect);
+ assert_outcome(&denied, &unowned, StopOutcome::Failed);
+ assert_eq!(
+ conn.query_row(
+ "SELECT event_id FROM desktop_stop_fence WHERE agent=?1",
+ [&target.agent],
+ |r| r.get::<_, String>(0)
+ )
+ .unwrap(),
+ next.id.to_hex()
+ );
+ let interrupted = request(&keys, &target, 103);
+ assert!(admit(&mut conn, &interrupted, &target).unwrap());
+ let unknown = receive_owned(&mut conn, &interrupted, true, no_effect);
+ assert_outcome(&unknown, &interrupted, StopOutcome::Unknown);
+ }
+
+ #[test]
+ fn durable_fence_survives_outcome_eviction_and_accepts_fresh_stop() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("stop.db");
+ let mut conn = open_retention_db(&path).unwrap();
+ let keys = Keys::generate();
+ let target = StopTarget {
+ v: 1,
+ community: "wss://one.example".into(),
+ desktop: "a".repeat(32),
+ agent: Keys::generate().public_key().to_hex(),
+ };
+ let first = request(&keys, &target, 100);
+ assert!(admit(&mut conn, &first, &target).unwrap());
+ assert!(!admit(&mut conn, &first, &target).unwrap());
+ for i in 0..HISTORY_LIMIT + 2 {
+ save_result(&mut conn, &format!("{i}"), "result").unwrap();
+ }
+ drop(conn);
+ let mut conn = open_retention_db(&path).unwrap();
+ assert!(!admit(&mut conn, &first, &target).unwrap());
+ assert!(admit(&mut conn, &request(&keys, &target, 101), &target).unwrap());
+ assert!(!admit(&mut conn, &request(&keys, &target, 99), &target).unwrap());
+ let a = request(&keys, &target, 102);
+ let b = request(&keys, &target, 102);
+ let (low, high) = if a.id < b.id { (a, b) } else { (b, a) };
+ assert!(admit(&mut conn, &high, &target).unwrap());
+ assert!(admit(&mut conn, &low, &target).unwrap());
+ assert!(!admit(&mut conn, &high, &target).unwrap());
+ }
}
diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md
index 1af98978be4..c196e8653c0 100644
--- a/desktop/src/features/agents/AGENTS.md
+++ b/desktop/src/features/agents/AGENTS.md
@@ -308,13 +308,18 @@ with a TypeScript lookup table or an id comparison in a component.
17. **Databricks model discovery has one shared catalog authority.** Desktop and ACP call the shared `buzz-agent` discovery library; Desktop passes the effective merged `DATABRICKS_MODEL_FILTER` explicitly, and the library applies it to raw workspace endpoint IDs and Unity Catalog model-service FQNs after the additive union. A successful filtered-empty catalog is authoritative: it stays empty, disables switching, and never falls through to configured or known-model fallback. UC FQNs are catalog data and always use the MLflow Chat Completions route, regardless of family-looking text in their components. Global Defaults preserves the discovered model ID as the selected value while its closed trigger renders the provider-scoped display label; do not force the raw persisted ID over that label.
-## Desktop Stop launch fence
+## Remote Desktop Stop
+Native IPC accepts an owner-private, explicitly selected agent+Desktop Stop,
+not inferred agent location. The relay redelivers stored Stop duplicates without
+repeating relay side effects.
+The receiver returns saved results or Unknown, never repeats a consumed Stop.
+Native owner-delegation and community checks
+precede durable admission and ordinary pair Stop. A delivery ACK is not success.
All local spawn paths consume the durable Stop fence at the shared native
spawn boundary. Only a deliberate **Start agent** action can supersede that
fence; config/restore/reconcile and Restart continuations cannot. Explicit Start
-captures its fence before preflight and fails if a newer Stop arrives. Fence
-release happens only after the new child has its receipt and tracked handle.
+captures its fence before preflight and fails if a newer Stop arrives.
## Channel-only runtime controls
From 17e08d74657370c59d054d2eb3492b1b098de1c4 Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 16:00:34 -0400
Subject: [PATCH 11/51] feat(desktop): add scope-bound Stop client and
exact-request retry
Signed-off-by: Logan Johnson
---
.../src/features/agents/desktopStop.test.mjs | 237 ++++++++++++++++++
desktop/src/features/agents/desktopStop.ts | 146 +++++++++++
2 files changed, 383 insertions(+)
create mode 100644 desktop/src/features/agents/desktopStop.test.mjs
create mode 100644 desktop/src/features/agents/desktopStop.ts
diff --git a/desktop/src/features/agents/desktopStop.test.mjs b/desktop/src/features/agents/desktopStop.test.mjs
new file mode 100644
index 00000000000..015a39480e2
--- /dev/null
+++ b/desktop/src/features/agents/desktopStop.test.mjs
@@ -0,0 +1,237 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ prepareStop,
+ readStopOutcome,
+ receiveStops,
+ sendStop,
+} from "./desktopStop.ts";
+
+const scope = { owner: "owner", community: "wss://one.example" };
+const request = {
+ id: "request",
+ kind: 50180,
+ pubkey: scope.owner,
+ tags: [["d", "desktop"]],
+};
+const result = {
+ id: "result",
+ kind: 50181,
+ pubkey: scope.owner,
+ tags: [["e", request.id]],
+};
+const tick = () => new Promise((resolve) => setImmediate(resolve));
+function fixture() {
+ let epoch = 0;
+ let live;
+ let closed = false;
+ let effect = 0;
+ let outcome;
+ let failResult = false;
+ const saved = new Map();
+ const stored = new Map();
+ const publishes = [];
+ const errors = [];
+ const ipc = async (command, args) => {
+ assert.equal(args.owner, scope.owner);
+ assert.equal(args.community, scope.community);
+ if (command === "prepare_desktop_stop") return request;
+ if (command === "receive_desktop_stop") {
+ if (!saved.has(args.event.id)) {
+ effect++;
+ saved.set(args.event.id, result);
+ }
+ return saved.get(args.event.id);
+ }
+ if (command === "read_desktop_stop_results") {
+ assert.equal(args.request, request);
+ return args.events.includes(result) ? "stopped" : "unknown";
+ }
+ throw Error(command);
+ };
+ const relay = {
+ getSessionEpoch: () => epoch,
+ publishEvent: async (event, _timeout, _failure, check) => {
+ check();
+ publishes.push(event);
+ if (event.kind === 50180) {
+ stored.set(event.id, event);
+ // Real relay contract: same immutable Stop is explicitly redelivered.
+ live?.(event);
+ } else {
+ if (failResult) throw Error("lost result publish");
+ outcome = event;
+ }
+ },
+ fetchEvents: async (filter) => {
+ assert.deepEqual(filter, {
+ kinds: [50181],
+ authors: [scope.owner],
+ "#e": [request.id],
+ limit: 16,
+ });
+ return outcome ? [outcome] : [];
+ },
+ subscribeLive: async (filter, onEvent, ready) => {
+ assert.deepEqual(filter, {
+ kinds: [50180],
+ authors: [scope.owner],
+ limit: 0,
+ });
+ live = onEvent;
+ ready("eose");
+ return () => {
+ live = undefined;
+ closed = true;
+ };
+ },
+ };
+ return {
+ ipc,
+ relay,
+ publishes,
+ errors,
+ stored,
+ saved,
+ effect: () => effect,
+ closed: () => closed,
+ deliver: () => live?.(request),
+ switchScope: () => {
+ epoch++;
+ },
+ failResult: (value) => {
+ failResult = value;
+ },
+ };
+}
+
+test("lost delivery/result recovers only on explicit exact-byte retry, not history replay", async () => {
+ const f = fixture();
+ const prepared = await prepareStop(
+ scope,
+ "desktop",
+ "agent",
+ () => true,
+ f.ipc,
+ f.relay,
+ );
+ await sendStop(scope, prepared, () => true, f.relay); // target absent
+ assert.equal(f.effect(), 0);
+ const close = await receiveStops(
+ scope,
+ () => true,
+ (e) => f.errors.push(e),
+ f.ipc,
+ f.relay,
+ );
+ assert.equal(
+ f.effect(),
+ 0,
+ "opening receiver cannot dispatch stored requests",
+ );
+ assert.equal(
+ await readStopOutcome(scope, request, () => true, f.ipc, f.relay),
+ "unknown",
+ );
+ assert.equal(f.effect(), 0, "status is read-only");
+ f.failResult(true);
+ await sendStop(scope, prepared, () => true, f.relay);
+ await tick();
+ assert.equal(f.effect(), 1);
+ assert.equal(f.errors.length, 1);
+ f.failResult(false);
+ await sendStop(scope, prepared, () => true, f.relay);
+ await tick();
+ assert.equal(
+ f.effect(),
+ 1,
+ "consumed request returns saved outcome without effect",
+ );
+ assert.equal(
+ await readStopOutcome(scope, request, () => true, f.ipc, f.relay),
+ "stopped",
+ );
+ assert.ok(
+ f.publishes.filter((e) => e.kind === 50180).every((e) => e === request),
+ );
+ assert.ok(
+ f.publishes.filter((e) => e.kind === 50181).every((e) => e === result),
+ );
+ close();
+ assert.equal(f.closed(), true);
+});
+
+test("duplicate delivery during native Stop/result publication is coalesced", async () => {
+ const f = fixture();
+ let release;
+ const wait = new Promise((resolve) => {
+ release = resolve;
+ });
+ let calls = 0;
+ const ipc = async (...args) => {
+ calls++;
+ await wait;
+ return f.ipc(...args);
+ };
+ const close = await receiveStops(
+ scope,
+ () => true,
+ () => {},
+ ipc,
+ f.relay,
+ );
+ f.deliver();
+ f.deliver();
+ assert.equal(calls, 1);
+ release();
+ await tick();
+ assert.equal(f.effect(), 1);
+ close();
+});
+
+test("scope change after native effect prevents result publication", async () => {
+ const f = fixture();
+ const ipc = async (...args) => {
+ const value = await f.ipc(...args);
+ f.switchScope();
+ return value;
+ };
+ const close = await receiveStops(
+ scope,
+ () => true,
+ () => {},
+ ipc,
+ f.relay,
+ );
+ f.deliver();
+ await tick();
+ assert.equal(f.effect(), 1, "dispatched Stop may finish");
+ assert.equal(
+ f.publishes.length,
+ 0,
+ "late result cannot cross the scope boundary",
+ );
+ close();
+});
+
+test("publish rate-limit/reconnect wait rechecks mounted owner scope before send", async () => {
+ const f = fixture();
+ let active = true;
+ f.relay.publishEvent = async (_event, _timeout, _failure, check) => {
+ active = false;
+ check();
+ };
+ await assert.rejects(
+ sendStop(scope, request, () => active, f.relay),
+ /scope changed/,
+ );
+ const ipc = async (...args) => {
+ const value = await f.ipc(...args);
+ f.switchScope();
+ return value;
+ };
+ await assert.rejects(
+ prepareStop(scope, "desktop", "agent", () => true, ipc, f.relay),
+ /scope changed/,
+ );
+});
diff --git a/desktop/src/features/agents/desktopStop.ts b/desktop/src/features/agents/desktopStop.ts
new file mode 100644
index 00000000000..3385813c691
--- /dev/null
+++ b/desktop/src/features/agents/desktopStop.ts
@@ -0,0 +1,146 @@
+import { invoke } from "@tauri-apps/api/core";
+import { relayClient } from "@/shared/api/relayClient";
+import type { RelayEvent } from "@/shared/api/types";
+import type { DesktopScope } from "./desktopList";
+
+export const DESKTOP_STOP = 50180;
+export const DESKTOP_STOP_RESULT = 50181;
+export type StopOutcome = "stopped" | "failed" | "unknown";
+
+function guard(
+ scope: DesktopScope,
+ active: () => boolean,
+ relay: typeof relayClient,
+) {
+ const epoch = relay.getSessionEpoch();
+ return () => {
+ if (!active() || relay.getSessionEpoch() !== epoch)
+ throw new Error(`Desktop Stop scope changed (${scope.community})`);
+ };
+}
+
+/** A mounted operation retains the exact signed request for explicit retry. */
+export async function prepareStop(
+ scope: DesktopScope,
+ desktop: string,
+ agent: string,
+ active: () => boolean,
+ ipc = invoke,
+ relay = relayClient,
+): Promise {
+ const check = guard(scope, active, relay);
+ check();
+ const request = await ipc("prepare_desktop_stop", {
+ ...scope,
+ desktop,
+ agent,
+ });
+ check();
+ return request;
+}
+
+/** ACK is delivery only; a missing authenticated correlated result is Unknown. */
+export async function sendStop(
+ scope: DesktopScope,
+ request: RelayEvent,
+ active: () => boolean,
+ relay = relayClient,
+): Promise {
+ const check = guard(scope, active, relay);
+ check();
+ await relay.publishEvent(
+ request,
+ "Stop delivery unconfirmed",
+ "Stop delivery failed",
+ check,
+ );
+ check();
+}
+
+export async function readStopOutcome(
+ scope: DesktopScope,
+ request: RelayEvent,
+ active: () => boolean,
+ ipc = invoke,
+ relay = relayClient,
+): Promise {
+ const check = guard(scope, active, relay);
+ check();
+ const events = await relay.fetchEvents({
+ kinds: [DESKTOP_STOP_RESULT],
+ authors: [scope.owner],
+ "#e": [request.id],
+ limit: 16,
+ });
+ check();
+ const outcome = await ipc("read_desktop_stop_results", {
+ ...scope,
+ request,
+ events,
+ });
+ check();
+ return outcome;
+}
+
+/** Live only: never fetch or replay historical commands when Desktop reopens. */
+export async function receiveStops(
+ scope: DesktopScope,
+ active: () => boolean,
+ onError: (message: string) => void,
+ ipc = invoke,
+ relay = relayClient,
+) {
+ const check = guard(scope, active, relay);
+ const pending = new Set();
+ check();
+ const unsubscribe = await relay.subscribeLive(
+ { kinds: [DESKTOP_STOP], authors: [scope.owner], limit: 0 },
+ (event) => {
+ if (!active()) return;
+ if (pending.has(event.id)) return;
+ if (pending.size >= 16) {
+ onError(
+ "Remote Stop receiver is busy. Unconfirmed requests can be retried.",
+ );
+ return;
+ }
+ pending.add(event.id);
+ void (async () => {
+ check();
+ const result = await ipc("receive_desktop_stop", {
+ ...scope,
+ event,
+ });
+ check();
+ if (result)
+ await relay.publishEvent(
+ result,
+ "Stop result delivery unconfirmed",
+ "Stop result delivery failed",
+ check,
+ );
+ check();
+ })()
+ .catch(() => {
+ if (active())
+ onError(
+ "A remote Stop result could not be confirmed. Retry the same Stop to request its saved outcome.",
+ );
+ })
+ .finally(() => {
+ pending.delete(event.id);
+ });
+ },
+ (readiness) => {
+ if (active() && readiness !== "eose")
+ onError("Remote Stop receiver is unavailable.");
+ },
+ );
+ try {
+ check();
+ } catch (error) {
+ unsubscribe();
+ throw error;
+ }
+ return unsubscribe;
+}
From 20436c81f21c65676686d9350faeed7df4d4b609 Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 16:00:35 -0400
Subject: [PATCH 12/51] feat(desktop): mount private remote Stop controls and
live receiver
Signed-off-by: Logan Johnson
---
desktop/src/features/agents/AGENTS.md | 7 +-
.../agents/ui/DesktopStopControl.test.mjs | 155 ++++++++++++++++
.../features/agents/ui/DesktopStopControl.tsx | 170 ++++++++++++++++++
.../src/features/agents/ui/KnownDesktops.tsx | 13 +-
4 files changed, 341 insertions(+), 4 deletions(-)
create mode 100644 desktop/src/features/agents/ui/DesktopStopControl.test.mjs
create mode 100644 desktop/src/features/agents/ui/DesktopStopControl.tsx
diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md
index c196e8653c0..be408c39b36 100644
--- a/desktop/src/features/agents/AGENTS.md
+++ b/desktop/src/features/agents/AGENTS.md
@@ -310,9 +310,10 @@ with a TypeScript lookup table or an id comparison in a component.
## Remote Desktop Stop
-Native IPC accepts an owner-private, explicitly selected agent+Desktop Stop,
-not inferred agent location. The relay redelivers stored Stop duplicates without
-repeating relay side effects.
+Known Desktops exposes an owner-private, explicitly selected agent+Desktop Stop,
+not inferred agent location. The app-scoped receiver subscribes live only;
+reopening never replays commands. An explicit retry republishes the exact request;
+the relay redelivers stored Stop duplicates without repeating relay side effects.
The receiver returns saved results or Unknown, never repeats a consumed Stop.
Native owner-delegation and community checks
precede durable admission and ordinary pair Stop. A delivery ACK is not success.
diff --git a/desktop/src/features/agents/ui/DesktopStopControl.test.mjs b/desktop/src/features/agents/ui/DesktopStopControl.test.mjs
new file mode 100644
index 00000000000..d7d8b1dfc1f
--- /dev/null
+++ b/desktop/src/features/agents/ui/DesktopStopControl.test.mjs
@@ -0,0 +1,155 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import React from "react";
+import { JSDOM } from "jsdom";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ DesktopStopControl,
+ DesktopStopReceiver,
+} from "./DesktopStopControl.tsx";
+import { relayClient } from "../../../shared/api/relayClient.ts";
+
+test("mounted Stop waits for a correlated result and retries identical bytes without replay", async () => {
+ const dom = new JSDOM("
", {
+ url: "https://desktop.test",
+ });
+ Object.assign(globalThis, {
+ window: dom.window,
+ document: dom.window.document,
+ localStorage: dom.window.localStorage,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ });
+ const { createRoot } = await import("react-dom/client");
+ const scope = { owner: "owner", community: "wss://one.example" };
+ const request = { id: "request", kind: 50180, tags: [["d", "desktop"]] };
+ const result = { id: "result", kind: 50181, tags: [["e", request.id]] };
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: Infinity } },
+ });
+ client.setQueryData(
+ ["relay-agents"],
+ [
+ { pubkey: "agent", name: "Owned agent", ownerPubkey: "owner" },
+ { pubkey: "foreign", name: "Foreign agent", ownerPubkey: "other" },
+ ],
+ );
+ const original = {
+ fetch: relayClient.fetchEvents,
+ publish: relayClient.publishEvent,
+ subscribe: relayClient.subscribeLive,
+ };
+ let live, release;
+ let receiveCalls = 0,
+ prepared = 0,
+ closed = 0;
+ const sent = [];
+ window.__TAURI_INTERNALS__ = {
+ invoke: async (command, args) => {
+ assert.equal(args.owner, scope.owner);
+ assert.equal(args.community, scope.community);
+ if (command === "prepare_desktop_stop") {
+ prepared++;
+ assert.equal(args.desktop, "desktop");
+ assert.equal(args.agent, "agent");
+ return request;
+ }
+ if (command === "receive_desktop_stop") {
+ receiveCalls++;
+ return result;
+ }
+ assert.equal(command, "read_desktop_stop_results");
+ return "stopped";
+ },
+ };
+ relayClient.subscribeLive = async (filter, callback) => {
+ assert.deepEqual(filter, {
+ kinds: [50180],
+ authors: [scope.owner],
+ limit: 0,
+ });
+ live = callback;
+ return () => {
+ closed++;
+ live = undefined;
+ };
+ };
+ relayClient.publishEvent = async (event) => {
+ sent.push(event);
+ live?.(event);
+ };
+ relayClient.fetchEvents = async (filter) => {
+ assert.deepEqual(filter, {
+ kinds: [50181],
+ authors: [scope.owner],
+ "#e": [request.id],
+ limit: 16,
+ });
+ return new Promise((resolve) => {
+ release = () => resolve([result]);
+ });
+ };
+ const root = createRoot(document.getElementById("root"));
+ const render = (receiver) =>
+ React.act(async () =>
+ root.render(
+ React.createElement(
+ QueryClientProvider,
+ { client },
+ React.createElement(
+ React.Fragment,
+ null,
+ receiver
+ ? React.createElement(DesktopStopReceiver, { scope })
+ : null,
+ React.createElement(DesktopStopControl, {
+ scope,
+ desktop: { id: "desktop", name: "Workshop" },
+ }),
+ ),
+ ),
+ ),
+ );
+ const click = (text) =>
+ React.act(async () =>
+ [...document.querySelectorAll("button")]
+ .find((b) => b.textContent === text)
+ .click(),
+ );
+ try {
+ await render(false);
+ assert.doesNotMatch(document.body.textContent, /Foreign agent/);
+ const select = document.querySelector("select");
+ await React.act(async () => {
+ select.value = "agent";
+ select.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
+ });
+ await click("Stop on Workshop");
+ assert.equal(prepared, 1);
+ assert.match(document.body.textContent, /Waiting for this Desktop/);
+ assert.doesNotMatch(document.body.textContent, /Stop confirmed/);
+ assert.equal(receiveCalls, 0, "absent receiver has not stopped anything");
+ await React.act(async () => release());
+ assert.match(document.body.textContent, /Stop confirmed by Workshop/);
+ await render(true);
+ assert.equal(receiveCalls, 0, "mount cannot replay stored Stop");
+ // The mounted receiver returns a saved native result, while the sender
+ // explicitly retries the exact prepared request rather than signing anew.
+ relayClient.publishEvent = async (event) => {
+ sent.push(event);
+ if (event.kind === 50180) live?.(event);
+ };
+ await click("Retry same Stop");
+ await React.act(async () => release());
+ assert.equal(prepared, 1);
+ assert.equal(receiveCalls, 1);
+ assert.ok(sent.filter((e) => e.kind === 50180).every((e) => e === request));
+ } finally {
+ await React.act(async () => root.unmount());
+ assert.equal(closed, 1);
+ client.clear();
+ relayClient.fetchEvents = original.fetch;
+ relayClient.publishEvent = original.publish;
+ relayClient.subscribeLive = original.subscribe;
+ dom.window.close();
+ }
+});
diff --git a/desktop/src/features/agents/ui/DesktopStopControl.tsx b/desktop/src/features/agents/ui/DesktopStopControl.tsx
new file mode 100644
index 00000000000..bd5e8f56cd7
--- /dev/null
+++ b/desktop/src/features/agents/ui/DesktopStopControl.tsx
@@ -0,0 +1,170 @@
+import { useEffect, useRef, useState } from "react";
+import { useRelayAgentsQuery } from "../hooks";
+import {
+ prepareStop,
+ readStopOutcome,
+ receiveStops,
+ sendStop,
+} from "../desktopStop";
+import type { DesktopScope, DesktopRow } from "../desktopList";
+import type { RelayEvent } from "@/shared/api/types";
+import { Button } from "@/shared/ui/button";
+
+/** App-scoped live receiver; no historical requests are loaded on mount. */
+export function DesktopStopReceiver({ scope }: { scope: DesktopScope | null }) {
+ const [error, setError] = useState("");
+ const { owner, community } = scope ?? {};
+ useEffect(() => {
+ if (!owner || !community) return;
+ let active = true;
+ let close: (() => void) | undefined;
+ setError("");
+ void receiveStops({ owner, community }, () => active, setError)
+ .then((unsubscribe) => {
+ if (active) close = unsubscribe;
+ else unsubscribe();
+ })
+ .catch(() => {
+ if (active)
+ setError("Remote Stop receiver is unavailable on this Desktop.");
+ });
+ return () => {
+ active = false;
+ close?.();
+ };
+ }, [owner, community]);
+ return error ? (
+
+ {error}
+
+ ) : null;
+}
+
+/** Deliberately selects a host, not an inferred running location or presence. */
+export function DesktopStopControl({
+ scope,
+ desktop,
+}: {
+ scope: DesktopScope;
+ desktop: DesktopRow;
+}) {
+ const agents = useRelayAgentsQuery();
+ const owned = (agents.data ?? []).filter(
+ (agent) => agent.ownerPubkey === scope.owner,
+ );
+ const [agent, setAgent] = useState("");
+ const [request, setRequest] = useState(null);
+ const [message, setMessage] = useState("");
+ const [busy, setBusy] = useState(false);
+ const active = useRef(true);
+ useEffect(() => {
+ active.current = true;
+ return () => {
+ active.current = false;
+ };
+ }, []);
+ const run = async (retry: boolean) => {
+ setBusy(true);
+ let current = retry ? request : null;
+ try {
+ current ??= await prepareStop(
+ scope,
+ desktop.id,
+ agent,
+ () => active.current,
+ );
+ if (!active.current) return;
+ setRequest(current);
+ setMessage("Stop requested. Waiting for this Desktop’s result…");
+ try {
+ await sendStop(scope, current, () => active.current);
+ } catch {
+ if (active.current)
+ setMessage(
+ "Delivery unconfirmed. Checking for this Desktop’s result…",
+ );
+ }
+ for (let attempt = 0; attempt < 15 && active.current; attempt++) {
+ const outcome = await readStopOutcome(
+ scope,
+ current,
+ () => active.current,
+ );
+ if (!active.current) return;
+ if (outcome === "stopped") {
+ setMessage(`Stop confirmed by ${desktop.name}.`);
+ return;
+ }
+ if (outcome === "failed") {
+ setMessage(
+ `Stop failed on ${desktop.name}. No success was confirmed.`,
+ );
+ return;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ }
+ if (active.current)
+ setMessage(
+ "Stop unconfirmed. This Desktop may be unavailable; its agents may still be running.",
+ );
+ } catch {
+ if (active.current)
+ setMessage("Stop unconfirmed. No successful result could be read.");
+ } finally {
+ if (active.current) setBusy(false);
+ }
+ };
+ return (
+
+
+ Agent to stop on {desktop.name}
+ {
+ setAgent(event.target.value);
+ setRequest(null);
+ setMessage("");
+ }}
+ >
+ Choose your agent
+ {owned.map((item) => (
+
+ {item.name}
+
+ ))}
+
+
+
+ Stops only this agent on this Desktop in this community. This list does
+ not establish where it is running.
+
+ {agents.isError &&
Your agent list is unavailable.
}
+
void run(false)}
+ >
+ Stop on {desktop.name}
+
+ {request && (
+
void run(true)}
+ >
+ Retry same Stop
+
+ )}
+ {message && (
+
+ {message}
+
+ )}
+
+ );
+}
diff --git a/desktop/src/features/agents/ui/KnownDesktops.tsx b/desktop/src/features/agents/ui/KnownDesktops.tsx
index 16d872c2d8b..45e7aa68eaa 100644
--- a/desktop/src/features/agents/ui/KnownDesktops.tsx
+++ b/desktop/src/features/agents/ui/KnownDesktops.tsx
@@ -1,3 +1,4 @@
+import { DesktopStopControl, DesktopStopReceiver } from "./DesktopStopControl";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useIdentityQuery } from "@/shared/api/hooks";
@@ -19,6 +20,7 @@ import {
import { DesktopCapabilityDetails } from "./DesktopCapabilityDetails";
type View = {
+ scope?: import("../desktopList").DesktopScope;
capabilities?: DesktopCapabilities[];
capabilityWarning?: string;
list: DesktopList | null;
@@ -78,7 +80,7 @@ export function DesktopListStartup() {
unsubscribe();
};
}, [refetch, pulse, report]);
- return null;
+ return ;
}
export function KnownDesktops() {
@@ -92,6 +94,7 @@ export function KnownDesktops() {
}, []);
return (
+ {scope && (
+
+ )}
item.id === row.id)}
now={now}
From c6be5382f1cae0ffee4ab30f0cbeb64e35aed51a Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 16:27:36 -0400
Subject: [PATCH 13/51] test(multiverse): exercise mounted Stop recovery and
private fanout in CI
Signed-off-by: Logan Johnson
---
Justfile | 4 +-
desktop/playwright.config.ts | 1 +
desktop/src/testing/e2eBridge.ts | 7 ++
desktop/tests/e2e/desktop-stop.spec.ts | 163 +++++++++++++++++++++++++
4 files changed, 174 insertions(+), 1 deletion(-)
create mode 100644 desktop/tests/e2e/desktop-stop.spec.ts
diff --git a/Justfile b/Justfile
index e44d3158777..3a18d0b321e 100644
--- a/Justfile
+++ b/Justfile
@@ -445,8 +445,10 @@ test-unit:
# non-postgres_tests cases only "pass" without a database by waiting out
# the ~30s sqlx acquire timeout, so they do not belong in the infra-free
# unit job either.
+ # The author-only fanout family uses lazy pools and in-memory recipients;
+ # include Stop request/result privacy and its sibling private kinds.
cargo nextest run -p buzz-relay --lib \
- -E '(test(/^api::admin::/) - test(=api::admin::tests::disabled_mode_allows_unauthenticated_requests_on_the_admin_host) - test(=api::admin::tests::nip98_mode_unrostered_signer_does_not_consume_a_replay_slot)) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/)'
+ -E '(test(/^api::admin::/) - test(=api::admin::tests::disabled_mode_allows_unauthenticated_requests_on_the_admin_host) - test(=api::admin::tests::nip98_mode_unrostered_signer_does_not_consume_a_replay_slot)) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^handlers::event::tests::fanout_access::.*_delivers_to_author_only$/)'
# ACP author-gate and queue tests protect the trust boundary between
# relay events and agent prompts. They are infra-free; ignored lifecycle
# tests remain excluded and run in their dedicated integration lanes.
diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index aad2580dad0..3ce7eb3b81f 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -20,6 +20,7 @@ export default defineConfig({
name: "smoke",
testMatch: [
"**/smoke.spec.ts",
+ "**/desktop-stop.spec.ts",
"**/owned-agent-discovery.spec.ts",
"**/thread-head-stale-edit.spec.ts",
"**/sidebar-offcanvas-rail.spec.ts",
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 6ddc1111b03..89b1fb20aed 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -11273,6 +11273,13 @@ function sendToMockSocket(args: {
return;
}
+ // Desktop inventory/control records are global-only. Native IPC owns their
+ // encryption and result validation; smoke fixtures supply that boundary.
+ if ([30180, 30181, 30182, 50180, 50181].includes(event.kind)) {
+ sendWsText(socket.handler, ["OK", event.id, true, ""]);
+ return;
+ }
+
const channelId = getChannelIdFromTags(event.tags);
if (!channelId) {
sendWsText(socket.handler, [
diff --git a/desktop/tests/e2e/desktop-stop.spec.ts b/desktop/tests/e2e/desktop-stop.spec.ts
new file mode 100644
index 00000000000..a656aa45623
--- /dev/null
+++ b/desktop/tests/e2e/desktop-stop.spec.ts
@@ -0,0 +1,163 @@
+import { expect, test } from "@playwright/test";
+import { installMockBridge } from "../helpers/bridge";
+import { waitForAnimations } from "../helpers/animations";
+
+// These are IPC fixtures, not native execution evidence. The real mounted
+// Known Desktops, client, relay publisher and retry control remain in the path.
+test("remote Stop distinguishes delivery, uncertainty, and confirmed result", async ({
+ page,
+}) => {
+ test.setTimeout(60_000);
+ const agent = "a7".repeat(32);
+ await installMockBridge(page, {
+ managedAgents: [],
+ relayAgents: [
+ {
+ pubkey: agent,
+ name: "Scout",
+ ownerPubkey: "deadbeef".repeat(8),
+ status: "unknown",
+ respondTo: "owner-only",
+ channelNames: [],
+ channelIds: [],
+ },
+ ],
+ });
+ await page.goto("/");
+ await expect(page.getByTestId("open-agents-view")).toBeVisible();
+ await page.evaluate(() => {
+ const w = window as typeof window & {
+ __STOP_FIXTURE__: {
+ confirmed: boolean;
+ prepared: number;
+ sends: string[];
+ };
+ __TAURI_INTERNALS__: {
+ invoke: (command: string, payload?: any, options?: any) => Promise;
+ };
+ };
+ w.__STOP_FIXTURE__ = { confirmed: false, prepared: 0, sends: [] };
+ const original = w.__TAURI_INTERNALS__.invoke.bind(w.__TAURI_INTERNALS__);
+ const now = Math.floor(Date.now() / 1000);
+ const local = "11111111-1111-4111-8111-111111111111";
+ const remote = "22222222-2222-4222-8222-222222222222";
+ const sign = async (kind: number, tags: string[][] = []) =>
+ JSON.parse(
+ await original("sign_event", {
+ kind,
+ tags,
+ content: "encrypted IPC fixture",
+ createdAt: now,
+ }),
+ );
+ w.__TAURI_INTERNALS__.invoke = async (command, payload, options) => {
+ switch (command) {
+ case "prepare_desktop_profile":
+ return { event: await sign(30180, [["d", local]]) };
+ case "read_desktop_profiles":
+ return [
+ { id: local, name: "Laptop", updated: now },
+ { id: remote, name: "Lab Desktop", updated: now },
+ ];
+ case "prepare_desktop_observation":
+ return { event: await sign(30181, [["d", local]]) };
+ case "read_desktop_observations":
+ return [
+ { id: local, heard: now },
+ { id: remote, heard: now - 600 },
+ ];
+ case "prepare_desktop_capabilities":
+ return { event: await sign(30182, [["d", local]]) };
+ case "read_desktop_capabilities":
+ return [local, remote].map((id) => ({
+ id,
+ reported: now,
+ runtimes: [],
+ }));
+ case "prepare_desktop_stop":
+ w.__STOP_FIXTURE__.prepared++;
+ return sign(50180, [
+ ["p", payload.owner],
+ ["d", payload.desktop],
+ ]);
+ case "receive_desktop_stop":
+ return null;
+ case "read_desktop_stop_results":
+ return w.__STOP_FIXTURE__.confirmed ? "stopped" : "unknown";
+ case "plugin:websocket|send": {
+ const wire = JSON.parse(payload.message.data);
+ if (wire[0] === "EVENT" && wire[1]?.kind === 50180)
+ w.__STOP_FIXTURE__.sends.push(JSON.stringify(wire[1]));
+ break;
+ }
+ }
+ return original(command, payload, options);
+ };
+ });
+ await page.getByTestId("open-agents-view").click();
+ const desktops = page.getByRole("region", { name: "Known Desktops" });
+ await desktops.getByRole("button", { name: "Refresh", exact: true }).click();
+ await expect(
+ desktops.getByText("Lab Desktop", { exact: true }),
+ ).toBeVisible();
+ await desktops
+ .getByRole("combobox", { name: "Agent to stop on Lab Desktop" })
+ .selectOption(agent);
+ await waitForAnimations(page);
+ await desktops.screenshot({
+ path: "test-results/desktop-stop/01-selected.png",
+ });
+
+ await desktops
+ .getByRole("button", { name: "Stop on Lab Desktop", exact: true })
+ .click();
+ await expect(
+ desktops.getByText("Stop requested. Waiting for this Desktop’s result…", {
+ exact: true,
+ }),
+ ).toBeVisible();
+ await expect(
+ desktops.getByText("Stop confirmed by Lab Desktop.", { exact: true }),
+ ).toHaveCount(0);
+ await waitForAnimations(page);
+ await desktops.screenshot({
+ path: "test-results/desktop-stop/02-waiting.png",
+ });
+
+ await expect(
+ desktops.getByText(
+ "Stop unconfirmed. This Desktop may be unavailable; its agents may still be running.",
+ { exact: true },
+ ),
+ ).toBeVisible({ timeout: 25_000 });
+ await waitForAnimations(page);
+ await desktops.screenshot({
+ path: "test-results/desktop-stop/03-unconfirmed.png",
+ });
+ await page.evaluate(() => {
+ (
+ window as typeof window & { __STOP_FIXTURE__: { confirmed: boolean } }
+ ).__STOP_FIXTURE__.confirmed = true;
+ });
+ await desktops
+ .getByRole("button", { name: "Retry same Stop", exact: true })
+ .click();
+ await expect(
+ desktops.getByText("Stop confirmed by Lab Desktop.", { exact: true }),
+ ).toBeVisible();
+ const result = await page.evaluate(
+ () =>
+ (
+ window as typeof window & {
+ __STOP_FIXTURE__: { prepared: number; sends: string[] };
+ }
+ ).__STOP_FIXTURE__,
+ );
+ expect(result.prepared).toBe(1);
+ expect(result.sends).toHaveLength(2);
+ expect(result.sends[1]).toBe(result.sends[0]);
+ await waitForAnimations(page);
+ await desktops.screenshot({
+ path: "test-results/desktop-stop/04-confirmed.png",
+ });
+});
From 83a2df13cf292500450b804cd788b67fd730ad5e Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 17:46:28 -0400
Subject: [PATCH 14/51] feat(multiverse): add private immutable lifecycle
transport
Signed-off-by: Logan Johnson
---
crates/buzz-core/src/desktop_lifecycle.rs | 195 ++++++++++++++++++
crates/buzz-core/src/desktop_stop.rs | 17 +-
crates/buzz-core/src/kind.rs | 8 +
crates/buzz-core/src/lib.rs | 1 +
crates/buzz-db/src/runtime/migration.rs | 29 ++-
.../src/api/desktop_profile_postgres_tests.rs | 58 +++++-
crates/buzz-relay/src/handlers/ingest.rs | 21 +-
migrations/0049_desktop_lifecycle_fts.sql | 26 +++
schema/schema.sql | 2 +-
9 files changed, 337 insertions(+), 20 deletions(-)
create mode 100644 crates/buzz-core/src/desktop_lifecycle.rs
create mode 100644 migrations/0049_desktop_lifecycle_fts.sql
diff --git a/crates/buzz-core/src/desktop_lifecycle.rs b/crates/buzz-core/src/desktop_lifecycle.rs
new file mode 100644
index 00000000000..c437830f4aa
--- /dev/null
+++ b/crates/buzz-core/src/desktop_lifecycle.rs
@@ -0,0 +1,195 @@
+//! Owner-private lifecycle requests. Signed order is intent, not process state.
+use crate::{
+ desktop_stop::{hex, read, sign, StopTarget},
+ kind::{KIND_DESKTOP_LIFECYCLE, KIND_DESKTOP_LIFECYCLE_RESULT},
+};
+use nostr::{Event, Keys, Tag};
+use serde::{Deserialize, Serialize};
+
+/// Start chooses a destination. Restart is a current-host-only one-shot.
+#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum Action {
+ /// Explicit ensure-running, without a remote reachability gate.
+ Start,
+ /// Ordinary Stop then one fresh launch, only on the resolved current host.
+ Restart,
+ /// Read actual local process status; never starts or stops anything.
+ Status,
+}
+
+/// Immutable request; retries retain its exact signed bytes.
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct Request {
+ /// Existing owner/community/agent/Desktop target shape.
+ pub target: StopTarget,
+ /// Requested operation, never shell text or configuration.
+ pub action: Action,
+ /// Restart's fresh successful Status request ID. None for other actions.
+ pub observed: Option,
+}
+
+/// No credentials, paths, PIDs or raw runtime errors on the wire.
+#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum Outcome {
+ /// Ordinary process registration/actual status confirms running locally.
+ Running,
+ /// Actual status confirms no managed process at this target.
+ Stopped,
+ /// Destination-local broker session issuance is not available.
+ ProvisioningUnavailable,
+ /// Runtime/readiness/ownership rejected the request.
+ Failed,
+ /// Superseded, interrupted, evicted or uncertain; never success.
+ Unknown,
+}
+
+/// Signed Desktop outcome, not agent-signed termination proof.
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct ResultMessage {
+ /// Original immutable payload.
+ pub request: Request,
+ /// Original signed event identity.
+ pub id: String,
+ /// Local Desktop result.
+ pub outcome: Outcome,
+}
+
+/// Public envelope gate before persistence; content remains owner encrypted.
+pub fn validate_envelope(event: &Event) -> Result<(), &'static str> {
+ let kind = event.kind.as_u16() as u32;
+ let tags: Vec<_> = event.tags.iter().map(|t| t.as_slice()).collect();
+ let result = kind == KIND_DESKTOP_LIFECYCLE_RESULT;
+ if !matches!(kind, KIND_DESKTOP_LIFECYCLE | KIND_DESKTOP_LIFECYCLE_RESULT)
+ || !(132..=4096).contains(&event.content.len())
+ || tags.len() != if result { 2 } else { 1 }
+ || tags[0].len() != 2
+ || tags[0][0] != "d"
+ || !hex(&tags[0][1], 32)
+ || (result && (tags[1].len() != 2 || tags[1][0] != "e" || !hex(&tags[1][1], 64)))
+ {
+ return Err("invalid Desktop lifecycle envelope");
+ }
+ Ok(())
+}
+
+impl Request {
+ /// Validate target, action and correlation without inventing credentials.
+ pub fn validate(&self, community: &str) -> Result<(), String> {
+ self.target.validate(community)?;
+ match (self.action, &self.observed) {
+ (Action::Restart, Some(id)) if hex(id, 64) => Ok(()),
+ (Action::Start | Action::Status, None) => Ok(()),
+ _ => Err("invalid Desktop lifecycle observation".into()),
+ }
+ }
+ /// Prepare once; retries must not create a new event/order.
+ pub fn sign(&self, keys: &Keys) -> Result {
+ self.validate(&self.target.community)?;
+ sign(
+ self,
+ keys,
+ KIND_DESKTOP_LIFECYCLE,
+ vec![Tag::identifier(&self.target.desktop)],
+ )
+ }
+ /// Authenticate owner, content, routing and captured community.
+ pub fn read(event: &Event, keys: &Keys, community: &str) -> Result {
+ let value: Self = read(event, keys, KIND_DESKTOP_LIFECYCLE)?;
+ value.validate(community)?;
+ if event.tags.identifier() != Some(value.target.desktop.as_str()) {
+ return Err("Desktop lifecycle routing mismatch".into());
+ }
+ Ok(value)
+ }
+}
+impl ResultMessage {
+ /// Sign the actual Desktop result. It is immutable for this request.
+ pub fn sign(&self, keys: &Keys) -> Result {
+ self.request.validate(&self.request.target.community)?;
+ if !hex(&self.id, 64) {
+ return Err("invalid lifecycle request ID".into());
+ }
+ sign(
+ self,
+ keys,
+ KIND_DESKTOP_LIFECYCLE_RESULT,
+ vec![
+ Tag::identifier(&self.request.target.desktop),
+ Tag::parse(["e", &self.id]).map_err(|e| e.to_string())?,
+ ],
+ )
+ }
+ /// Bind every correlation field to the original authenticated request.
+ pub fn read(
+ event: &Event,
+ keys: &Keys,
+ request: &Event,
+ community: &str,
+ ) -> Result {
+ let original = Request::read(request, keys, community)?;
+ let value: Self = read(event, keys, KIND_DESKTOP_LIFECYCLE_RESULT)?;
+ if value.request != original
+ || value.id != request.id.to_hex()
+ || event.tags.identifier() != Some(original.target.desktop.as_str())
+ || event.tags.iter().nth(1).and_then(|t| t.content()) != Some(value.id.as_str())
+ {
+ return Err("Desktop lifecycle result mismatch".into());
+ }
+ Ok(value)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[test]
+ fn scope_action_and_result_are_bound_to_one_signed_request() {
+ let keys = Keys::generate();
+ let mut request = Request {
+ target: StopTarget {
+ v: 1,
+ community: "wss://one.example".into(),
+ desktop: "a".repeat(32),
+ agent: Keys::generate().public_key().to_hex(),
+ },
+ action: Action::Start,
+ observed: None,
+ };
+ let event = request.sign(&keys).unwrap();
+ assert_eq!(
+ Request::read(&event, &keys, &request.target.community).unwrap(),
+ request
+ );
+ assert!(Request::read(&event, &Keys::generate(), &request.target.community).is_err());
+ assert!(Request::read(&event, &keys, "wss://other.example").is_err());
+ assert!(
+ crate::desktop_stop::StopTarget::read(&event, &keys, &request.target.community)
+ .is_err()
+ );
+ let result = ResultMessage {
+ request: request.clone(),
+ id: event.id.to_hex(),
+ outcome: Outcome::ProvisioningUnavailable,
+ }
+ .sign(&keys)
+ .unwrap();
+ assert_eq!(
+ ResultMessage::read(&result, &keys, &event, &request.target.community)
+ .unwrap()
+ .outcome,
+ Outcome::ProvisioningUnavailable
+ );
+ let other = request.sign(&keys).unwrap();
+ assert!(ResultMessage::read(&result, &keys, &other, &request.target.community).is_err());
+ request.action = Action::Restart;
+ assert!(request.sign(&keys).is_err());
+ request.observed = Some(event.id.to_hex());
+ assert!(request.sign(&keys).is_ok());
+ request.action = Action::Start;
+ assert!(request.sign(&keys).is_err());
+ }
+}
diff --git a/crates/buzz-core/src/desktop_stop.rs b/crates/buzz-core/src/desktop_stop.rs
index 4f431655e99..9291c323c8b 100644
--- a/crates/buzz-core/src/desktop_stop.rs
+++ b/crates/buzz-core/src/desktop_stop.rs
@@ -42,7 +42,7 @@ pub struct StopResult {
pub outcome: StopOutcome,
}
-fn hex(value: &str, len: usize) -> bool {
+pub(crate) fn hex(value: &str, len: usize) -> bool {
value.len() == len
&& value
.bytes()
@@ -67,7 +67,12 @@ pub fn validate_envelope(event: &Event) -> Result<(), &'static str> {
Ok(())
}
-fn sign(value: &T, keys: &Keys, kind: u32, tags: Vec) -> Result {
+pub(crate) fn sign(
+ value: &T,
+ keys: &Keys,
+ kind: u32,
+ tags: Vec,
+) -> Result {
let ciphertext = nip44::encrypt(
keys.secret_key(),
&keys.public_key(),
@@ -81,12 +86,16 @@ fn sign(value: &T, keys: &Keys, kind: u32, tags: Vec) -> Resu
.map_err(|e| e.to_string())
}
-fn read(
+pub(crate) fn read(
event: &Event,
keys: &Keys,
kind: u32,
) -> Result {
- validate_envelope(event)?;
+ if matches!(kind, KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT) {
+ validate_envelope(event)?;
+ } else {
+ crate::desktop_lifecycle::validate_envelope(event)?;
+ }
event
.verify()
.map_err(|_| "invalid Desktop Stop signature")?;
diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs
index 5df974c36cb..3146c511a28 100644
--- a/crates/buzz-core/src/kind.rs
+++ b/crates/buzz-core/src/kind.rs
@@ -129,6 +129,10 @@ pub const KIND_DESKTOP_CAPABILITIES: u32 = 30182;
pub const KIND_DESKTOP_STOP: u32 = 50180;
/// Owner-private ordinary Desktop Stop outcome, correlated by request event ID.
pub const KIND_DESKTOP_STOP_RESULT: u32 = 50181;
+/// Owner-private Start/Restart/status request, separate from legacy Stop.
+pub const KIND_DESKTOP_LIFECYCLE: u32 = 50182;
+/// Correlated owner-private Desktop lifecycle result.
+pub const KIND_DESKTOP_LIFECYCLE_RESULT: u32 = 50183;
/// Kinds whose stored events are readable only by their author.
///
@@ -148,6 +152,8 @@ pub const AUTHOR_ONLY_KINDS: &[u32] = &[
KIND_DESKTOP_CAPABILITIES,
KIND_DESKTOP_STOP,
KIND_DESKTOP_STOP_RESULT,
+ KIND_DESKTOP_LIFECYCLE,
+ KIND_DESKTOP_LIFECYCLE_RESULT,
];
/// Kinds that require a result-level read gate beyond the filter-layer
@@ -682,6 +688,8 @@ pub const ALL_KINDS: &[u32] = &[
KIND_DESKTOP_CAPABILITIES,
KIND_DESKTOP_STOP,
KIND_DESKTOP_STOP_RESULT,
+ KIND_DESKTOP_LIFECYCLE,
+ KIND_DESKTOP_LIFECYCLE_RESULT,
KIND_REPORT,
KIND_PRODUCT_FEEDBACK,
KIND_NIP29_PUT_USER,
diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs
index 4b3874be0f0..d80446ac35e 100644
--- a/crates/buzz-core/src/lib.rs
+++ b/crates/buzz-core/src/lib.rs
@@ -10,6 +10,7 @@ pub mod agent_turn_metric;
/// Channel and membership enums shared across crates.
pub mod channel;
pub mod desktop_capabilities;
+pub mod desktop_lifecycle;
pub mod desktop_observation;
/// Owner-private Desktop display profiles.
pub mod desktop_profile;
diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs
index 35261123b61..8d2bf6ea697 100644
--- a/crates/buzz-db/src/runtime/migration.rs
+++ b/crates/buzz-db/src/runtime/migration.rs
@@ -702,7 +702,7 @@ mod postgres_tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);
- assert_eq!(migrations.len(), 48);
+ assert_eq!(migrations.len(), 49);
assert_eq!(migrations[0].version, 1);
assert_eq!(&*migrations[0].description, "initial schema");
assert!(migrations[0]
@@ -911,7 +911,7 @@ mod postgres_tests {
assert!(migrations[32].sql.as_str().contains("search_tsv"));
assert!(!migrations[0].sql.as_str().contains("30179"));
assert!(include_str!("../../../../schema/schema.sql").contains(
- "kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200, 50180, 50181)"
+ "kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200, 50180, 50181, 50182, 50183)"
));
// Public push-gateway authority is intentionally deployment-global and
@@ -2396,6 +2396,8 @@ mod postgres_tests {
(6_u8, 30_182_i32),
(7_u8, 50_180_i32),
(8_u8, 50_181_i32),
+ (9_u8, 50_182_i32),
+ (10_u8, 50_183_i32),
] {
sqlx::query(
"INSERT INTO events \
@@ -2433,7 +2435,9 @@ mod postgres_tests {
(30_182, true),
(30_350, true),
(50_180, true),
- (50_181, true)
+ (50_181, true),
+ (50_182, true),
+ (50_183, true)
]
);
@@ -2460,7 +2464,9 @@ mod postgres_tests {
(30_182, Some(true)),
(30_350, None),
(50_180, Some(true)),
- (50_181, Some(true))
+ (50_181, Some(true)),
+ (50_182, Some(true)),
+ (50_183, Some(true))
]
);
@@ -2507,6 +2513,17 @@ mod postgres_tests {
.await
.unwrap();
assert_eq!(stop_indexed, 2, "0048 must change brownfield Stop FTS");
+ run_migrations_through(&pool, 48).await.unwrap();
+ let lifecycle_indexed: i64 = sqlx::query_scalar(
+ "SELECT count(*) FROM events WHERE kind IN (50182, 50183) AND search_tsv IS NOT NULL",
+ )
+ .fetch_one(&pool)
+ .await
+ .unwrap();
+ assert_eq!(
+ lifecycle_indexed, 2,
+ "0049 must change populated lifecycle FTS"
+ );
run_migrations(&pool)
.await
@@ -2528,7 +2545,9 @@ mod postgres_tests {
(30_182, None),
(30_350, None),
(50_180, None),
- (50_181, None)
+ (50_181, None),
+ (50_182, None),
+ (50_183, None)
]
);
let gin_exists: bool = sqlx::query_scalar(
diff --git a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs
index ac35a0dd12e..29b21068d37 100644
--- a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs
+++ b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs
@@ -3,8 +3,8 @@ use super::postgres_tests::bridge_handler_test_state;
use super::*;
use axum::{body::Body, http::Request};
use buzz_core::kind::{
- KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE, KIND_DESKTOP_STOP,
- KIND_DESKTOP_STOP_RESULT,
+ KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_LIFECYCLE, KIND_DESKTOP_LIFECYCLE_RESULT,
+ KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE, KIND_DESKTOP_STOP, KIND_DESKTOP_STOP_RESULT,
};
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
use serde_json::json;
@@ -92,6 +92,13 @@ async fn desktop_stop_authenticated_owner_query_and_private_storage() {
assert_private_desktop(KIND_DESKTOP_STOP_RESULT).await;
}
+#[tokio::test]
+#[ignore = "requires Postgres"]
+async fn desktop_lifecycle_authenticated_owner_query_and_private_storage() {
+ assert_private_desktop(KIND_DESKTOP_LIFECYCLE).await;
+ assert_private_desktop(KIND_DESKTOP_LIFECYCLE_RESULT).await;
+}
+
async fn assert_private_desktop(kind: u32) {
let mut state = bridge_handler_test_state()
.await
@@ -132,6 +139,29 @@ async fn assert_private_desktop(kind: u32) {
.sign(&owner)
.unwrap()
}
+ } else if matches!(kind, KIND_DESKTOP_LIFECYCLE | KIND_DESKTOP_LIFECYCLE_RESULT) {
+ let request = buzz_core::desktop_lifecycle::Request {
+ target: buzz_core::desktop_stop::StopTarget {
+ v: 1,
+ community: format!("wss://{host}"),
+ desktop: id.clone(),
+ agent: Keys::generate().public_key().to_hex(),
+ },
+ action: buzz_core::desktop_lifecycle::Action::Start,
+ observed: None,
+ };
+ let event = request.sign(&owner).unwrap();
+ if kind == KIND_DESKTOP_LIFECYCLE {
+ event
+ } else {
+ buzz_core::desktop_lifecycle::ResultMessage {
+ request,
+ id: event.id.to_hex(),
+ outcome: buzz_core::desktop_lifecycle::Outcome::Running,
+ }
+ .sign(&owner)
+ .unwrap()
+ }
} else if kind == KIND_DESKTOP_PROFILE {
profile.sign(&owner).unwrap()
} else if kind == KIND_DESKTOP_CAPABILITIES {
@@ -320,6 +350,14 @@ async fn aged_desktop_profile_retries_through_production_ingest_without_resignin
#[tokio::test]
#[ignore = "requires Postgres and Redis"]
async fn desktop_stop_retry_redelivers_exact_event_only_to_owner() {
+ assert_retry(KIND_DESKTOP_STOP).await;
+}
+#[tokio::test]
+#[ignore = "requires Postgres and Redis"]
+async fn desktop_lifecycle_retry_redelivers_exact_event_only_to_owner() {
+ assert_retry(KIND_DESKTOP_LIFECYCLE).await;
+}
+async fn assert_retry(kind: u32) {
use nostr::Filter;
use std::sync::atomic::AtomicU8;
use tokio::sync::{mpsc, Mutex};
@@ -343,7 +381,17 @@ async fn desktop_stop_retry_redelivers_exact_event_only_to_owner() {
desktop: uuid::Uuid::new_v4().simple().to_string(),
agent: Keys::generate().public_key().to_hex(),
};
- let prepared = target.sign(&owner).unwrap();
+ let prepared = if kind == KIND_DESKTOP_STOP {
+ target.sign(&owner).unwrap()
+ } else {
+ buzz_core::desktop_lifecycle::Request {
+ target,
+ action: buzz_core::desktop_lifecycle::Action::Start,
+ observed: None,
+ }
+ .sign(&owner)
+ .unwrap()
+ };
let event = EventBuilder::new(prepared.kind, &prepared.content)
.tags(prepared.tags.iter().cloned())
.custom_created_at(Timestamp::from(Timestamp::now().as_secs() - 86_400))
@@ -383,7 +431,7 @@ async fn desktop_stop_retry_redelivers_exact_event_only_to_owner() {
tenant,
conn,
"stop".into(),
- vec![Filter::new().kind(Kind::Custom(KIND_DESKTOP_STOP as u16))],
+ vec![Filter::new().kind(Kind::Custom(kind as u16))],
None,
);
receivers.push(rx);
@@ -407,6 +455,6 @@ async fn desktop_stop_retry_redelivers_exact_event_only_to_owner() {
assert_eq!(status, StatusCode::FORBIDDEN, "{result}");
assert!(drain(&mut receivers[0]).is_empty());
let (_, rows) = post(&state, &host, "/query", &owner,
- json!([{"kinds":[KIND_DESKTOP_STOP],"authors":[owner.public_key().to_hex()], "ids":[event.id.to_hex()]}]), true).await;
+ json!([{"kinds":[kind],"authors":[owner.public_key().to_hex()], "ids":[event.id.to_hex()]}]), true).await;
assert_eq!(rows.as_array().unwrap().len(), 1);
}
diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs
index 112bea02085..88d2d43ed4e 100644
--- a/crates/buzz-relay/src/handlers/ingest.rs
+++ b/crates/buzz-relay/src/handlers/ingest.rs
@@ -37,8 +37,8 @@ use buzz_core::kind::{
RELAY_ADMIN_SET_WORKSPACE_PROFILE,
};
use buzz_core::kind::{
- KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE, KIND_DESKTOP_STOP,
- KIND_DESKTOP_STOP_RESULT,
+ KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_LIFECYCLE, KIND_DESKTOP_LIFECYCLE_RESULT,
+ KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE, KIND_DESKTOP_STOP, KIND_DESKTOP_STOP_RESULT,
};
use buzz_core::tenant::TenantContext;
use buzz_core::verification::verify_event;
@@ -440,7 +440,7 @@ fn map_push_accept_error(error: super::push_lease::AcceptError) -> IngestError {
/// Returns `Err` for unknown kinds — the relay rejects them.
fn required_scope_for_kind(kind: u32, event: &Event) -> Result {
match kind {
- KIND_PROFILE | KIND_DESKTOP_PROFILE | KIND_DESKTOP_OBSERVATION | KIND_DESKTOP_CAPABILITIES | KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT => Ok(Scope::UsersWrite),
+ KIND_PROFILE | KIND_DESKTOP_PROFILE | KIND_DESKTOP_OBSERVATION | KIND_DESKTOP_CAPABILITIES | KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT | KIND_DESKTOP_LIFECYCLE | KIND_DESKTOP_LIFECYCLE_RESULT => Ok(Scope::UsersWrite),
KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite),
KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM
| KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT
@@ -665,7 +665,7 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
| KIND_DESKTOP_OBSERVATION
| KIND_DESKTOP_CAPABILITIES
| KIND_DESKTOP_STOP
- | KIND_DESKTOP_STOP_RESULT
+ | KIND_DESKTOP_STOP_RESULT | KIND_DESKTOP_LIFECYCLE | KIND_DESKTOP_LIFECYCLE_RESULT
| KIND_TEAM_CATALOG
// NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope).
// Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag).
@@ -2189,6 +2189,8 @@ fn timestamp_within_ingest_window(kind: u32, event_ts: u64, now: u64) -> bool {
| KIND_DESKTOP_CAPABILITIES
| KIND_DESKTOP_STOP
| KIND_DESKTOP_STOP_RESULT
+ | KIND_DESKTOP_LIFECYCLE
+ | KIND_DESKTOP_LIFECYCLE_RESULT
) || now.saturating_sub(event_ts) <= MAX_TIMESTAMP_DRIFT_SECS)
}
@@ -2804,6 +2806,13 @@ async fn ingest_event_inner(
}
}
+ if matches!(
+ kind_u32,
+ KIND_DESKTOP_LIFECYCLE | KIND_DESKTOP_LIFECYCLE_RESULT
+ ) {
+ buzz_core::desktop_lifecycle::validate_envelope(&event)
+ .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
+ }
if matches!(kind_u32, KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT) {
buzz_core::desktop_stop::validate_envelope(&event)
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
@@ -3250,7 +3259,7 @@ async fn ingest_event_inner(
// Stop is a one-shot owned by Desktop, not a replaceable projection.
// Explicit transport retry must reach a live receiver even after an ACK
// or its result was lost. Never replay history or repeat relay effects.
- if kind_u32 == KIND_DESKTOP_STOP {
+ if matches!(kind_u32, KIND_DESKTOP_STOP | KIND_DESKTOP_LIFECYCLE) {
super::event::redeliver_desktop_stop(tenant, state, &stored_event.event).await;
}
return Ok(IngestResult {
@@ -3391,6 +3400,8 @@ mod postgres_tests {
| KIND_DESKTOP_CAPABILITIES
| KIND_DESKTOP_STOP
| KIND_DESKTOP_STOP_RESULT
+ | KIND_DESKTOP_LIFECYCLE
+ | KIND_DESKTOP_LIFECYCLE_RESULT
) {
profile
} else {
diff --git a/migrations/0049_desktop_lifecycle_fts.sql b/migrations/0049_desktop_lifecycle_fts.sql
new file mode 100644
index 00000000000..ad42a795a2e
--- /dev/null
+++ b/migrations/0049_desktop_lifecycle_fts.sql
@@ -0,0 +1,26 @@
+-- Owner-private Desktop lifecycle requests and results must not enter legacy ciphertext search indexes.
+-- Like 0033, this rewrites events under ACCESS EXCLUSIVE; schedule accordingly.
+DO $$
+DECLARE
+ existing_expression TEXT;
+BEGIN
+ SELECT pg_get_expr(d.adbin, d.adrelid)
+ INTO existing_expression
+ FROM pg_attrdef d
+ JOIN pg_attribute a
+ ON a.attrelid = d.adrelid
+ AND a.attnum = d.adnum
+ WHERE d.adrelid = 'events'::regclass
+ AND a.attname = 'search_tsv';
+
+ IF existing_expression IS NULL THEN
+ RAISE EXCEPTION 'events.search_tsv generated expression not found';
+ END IF;
+
+ ALTER TABLE events DROP COLUMN search_tsv;
+ EXECUTE format(
+ 'ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS (CASE WHEN kind IN (50182, 50183) THEN NULL::tsvector ELSE (%s) END) STORED',
+ existing_expression
+ );
+ CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv);
+END $$;
diff --git a/schema/schema.sql b/schema/schema.sql
index 0a20e940285..03606b1645f 100644
--- a/schema/schema.sql
+++ b/schema/schema.sql
@@ -221,7 +221,7 @@ CREATE TABLE events (
-- never matches `@@`.
-- Keep in sync with migrations (final state: 0001 + 0005 + 0014 + 0033).
search_tsv TSVECTOR GENERATED ALWAYS AS (
- CASE WHEN kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200, 50180, 50181) THEN NULL::tsvector
+ CASE WHEN kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200, 50180, 50181, 50182, 50183) THEN NULL::tsvector
ELSE to_tsvector('simple', content)
END
) STORED,
From b85c53293ff4d84085668050539a99c1d99537d4 Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 17:46:29 -0400
Subject: [PATCH 15/51] feat(desktop): persist ordered placement and lifecycle
admission
Signed-off-by: Logan Johnson
---
.../src-tauri/src/managed_agents/placement.rs | 217 +++++++++++++
.../src/managed_agents/placement/tests.rs | 284 ++++++++++++++++++
2 files changed, 501 insertions(+)
create mode 100644 desktop/src-tauri/src/managed_agents/placement.rs
create mode 100644 desktop/src-tauri/src/managed_agents/placement/tests.rs
diff --git a/desktop/src-tauri/src/managed_agents/placement.rs b/desktop/src-tauri/src/managed_agents/placement.rs
new file mode 100644
index 00000000000..13efe7a8b19
--- /dev/null
+++ b/desktop/src-tauri/src/managed_agents/placement.rs
@@ -0,0 +1,217 @@
+//! Compact intent, separate from one-shot execution. No history replay.
+use buzz_core_pkg::{
+ desktop_lifecycle::{Action, Outcome, Request, ResultMessage},
+ desktop_stop::StopTarget,
+ kind::KIND_DESKTOP_STOP,
+};
+use nostr::{Event, JsonUtil, Keys};
+use rusqlite::{params, Connection, OptionalExtension};
+
+pub(crate) fn schema(conn: &Connection) -> Result<(), String> {
+ conn.execute_batch(
+ "CREATE TABLE IF NOT EXISTS desktop_placement (
+ agent TEXT NOT NULL, slot TEXT NOT NULL, host TEXT NOT NULL, stamp INTEGER NOT NULL,
+ id TEXT NOT NULL, PRIMARY KEY(agent,slot));
+ CREATE TABLE IF NOT EXISTS desktop_lifecycle_admission (
+ agent TEXT NOT NULL, host TEXT NOT NULL, action TEXT NOT NULL, stamp INTEGER NOT NULL,
+ id TEXT NOT NULL, PRIMARY KEY(agent,host,action));
+ CREATE TABLE IF NOT EXISTS desktop_lifecycle_results (
+ id TEXT PRIMARY KEY, raw TEXT NOT NULL);",
+ )
+ .map_err(|e| e.to_string())
+}
+
+/// Start's shared max and each host's Stop max are sufficient statistics.
+/// Stopping newest Start never falls back to an earlier host.
+pub(crate) fn observe(
+ conn: &Connection,
+ event: &Event,
+ keys: &Keys,
+ community: &str,
+) -> Result {
+ let (target, slot) = if event.kind.as_u16() as u32 == KIND_DESKTOP_STOP {
+ let target = StopTarget::read(event, keys, community)?;
+ let slot = format!("stop:{}", target.desktop);
+ (target, slot)
+ } else {
+ let request = Request::read(event, keys, community)?;
+ if request.action != Action::Start {
+ return Ok(request.target.agent);
+ }
+ (request.target, "start".into())
+ };
+ schema(conn)?;
+ conn.execute(
+ "INSERT INTO desktop_placement VALUES (?1,?2,?3,?4,?5)
+ ON CONFLICT(agent,slot) DO UPDATE SET host=excluded.host,stamp=excluded.stamp,id=excluded.id
+ WHERE excluded.stamp > desktop_placement.stamp OR
+ (excluded.stamp = desktop_placement.stamp AND excluded.id < desktop_placement.id)",
+ params![
+ target.agent,
+ slot,
+ target.desktop,
+ event.created_at.as_secs(),
+ event.id.to_hex()
+ ],
+ )
+ .map_err(|e| e.to_string())?;
+ Ok(target.agent)
+}
+
+type Row = (String, u64, String);
+fn row(conn: &Connection, agent: &str, slot: &str) -> Result, String> {
+ schema(conn)?;
+ conn.query_row(
+ "SELECT host,stamp,id FROM desktop_placement WHERE agent=?1 AND slot=?2",
+ params![agent, slot],
+ |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
+ )
+ .optional()
+ .map_err(|e| e.to_string())
+}
+fn newer(a: &Row, b: &Row) -> bool {
+ a.1 > b.1 || (a.1 == b.1 && a.2 < b.2)
+}
+
+/// Some Start remains desired, or none. Intent does not establish process state.
+pub(crate) fn desired(conn: &Connection, agent: &str) -> Result , String> {
+ let Some(start) = row(conn, agent, "start")? else {
+ return Ok(None);
+ };
+ if row(conn, agent, &format!("stop:{}", start.0))?.is_some_and(|stop| newer(&stop, &start)) {
+ return Ok(None);
+ }
+ Ok(Some((start.0, start.2)))
+}
+
+/// Unknown preserves existing local behavior; known supersession blocks every spawn.
+pub(crate) fn blocked(conn: &Connection, agent: &str, host: &str) -> Result {
+ let start = row(conn, agent, "start")?;
+ let stop = row(conn, agent, &format!("stop:{host}"))?;
+ Ok(match (start, stop) {
+ (None, Some(_)) => true,
+ (None, None) => false,
+ (Some(start), Some(stop)) if newer(&stop, &start) => true,
+ (Some(start), _) => start.0 != host,
+ })
+}
+
+/// Durable high-water marks are never evicted with diagnostic/result history.
+pub(crate) fn admit(conn: &Connection, event: &Event, request: &Request) -> Result {
+ schema(conn)?;
+ let action = match request.action {
+ Action::Start => "start",
+ Action::Restart => "restart",
+ Action::Status => "status",
+ };
+ let changed = conn.execute("INSERT INTO desktop_lifecycle_admission VALUES (?1,?2,?3,?4,?5)
+ ON CONFLICT(agent,host,action) DO UPDATE SET stamp=excluded.stamp,id=excluded.id
+ WHERE excluded.stamp > desktop_lifecycle_admission.stamp OR
+ (excluded.stamp = desktop_lifecycle_admission.stamp AND excluded.id < desktop_lifecycle_admission.id)",
+ params![request.target.agent,request.target.desktop,action,event.created_at.as_secs(),event.id.to_hex()]).map_err(|e| e.to_string())?;
+ Ok(changed == 1)
+}
+pub(crate) fn saved(conn: &Connection, id: &str) -> Result, String> {
+ schema(conn)?;
+ let raw: Option = conn
+ .query_row(
+ "SELECT raw FROM desktop_lifecycle_results WHERE id=?1",
+ [id],
+ |r| r.get(0),
+ )
+ .optional()
+ .map_err(|e| e.to_string())?;
+ raw.map(|s| Event::from_json(s).map_err(|e| e.to_string()))
+ .transpose()
+}
+pub(crate) fn save(conn: &mut Connection, id: &str, event: &Event) -> Result<(), String> {
+ schema(conn)?;
+ let tx = conn.transaction().map_err(|e| e.to_string())?;
+ tx.execute(
+ "INSERT OR IGNORE INTO desktop_lifecycle_results VALUES (?1,?2)",
+ params![id, event.as_json()],
+ )
+ .map_err(|e| e.to_string())?;
+ tx.execute("DELETE FROM desktop_lifecycle_results WHERE rowid NOT IN (SELECT rowid FROM desktop_lifecycle_results ORDER BY rowid DESC LIMIT 256)", []).map_err(|e|e.to_string())?;
+ tx.commit().map_err(|e| e.to_string())
+}
+
+#[cfg(test)]
+mod tests;
+
+pub(crate) fn has_start(conn: &Connection, agent: &str) -> Result {
+ Ok(row(conn, agent, "start")?.is_some())
+}
+pub(crate) fn latest_start(
+ conn: &Connection,
+ agent: &str,
+) -> Result, String> {
+ Ok(row(conn, agent, "start")?.map(|(host, _, id)| (host, id)))
+}
+
+/// A newer local Stop invalidates stale Restart even after a subsequent Start.
+pub(crate) fn stale_restart(
+ conn: &Connection,
+ event: &Event,
+ request: &Request,
+) -> Result {
+ let command = (
+ request.target.desktop.clone(),
+ event.created_at.as_secs(),
+ event.id.to_hex(),
+ );
+ Ok(row(
+ conn,
+ &request.target.agent,
+ &format!("stop:{}", request.target.desktop),
+ )?
+ .is_some_and(|stop| newer(&stop, &command)))
+}
+
+/// Authenticate and consume before effects; crashes and evicted results never
+/// turn an exact retry into a fresh launch or Restart.
+pub(crate) fn receive(
+ conn: &mut Connection,
+ event: &Event,
+ keys: &Keys,
+ community: &str,
+ desktop: &str,
+ owned: bool,
+ effect: impl FnOnce(&Connection, &Request) -> Result,
+) -> Result, String> {
+ let request = Request::read(event, keys, community)?;
+ observe(conn, event, keys, community)?;
+ if request.target.desktop != desktop {
+ return Ok(None);
+ }
+ if let Some(saved) = saved(conn, &event.id.to_hex())? {
+ ResultMessage::read(&saved, keys, event, community)?;
+ return Ok(Some(saved));
+ }
+ let outcome = if !owned {
+ Outcome::Failed
+ } else if !admit(conn, event, &request)?
+ || (request.action != Action::Status
+ && (blocked(conn, &request.target.agent, desktop)?
+ || (request.action == Action::Restart && stale_restart(conn, event, &request)?)
+ || (request.action == Action::Start
+ && desired(conn, &request.target.agent)?.map(|(_, id)| id)
+ != Some(event.id.to_hex()))))
+ {
+ Outcome::Unknown
+ } else {
+ effect(conn, &request).unwrap_or(if request.action == Action::Status {
+ Outcome::Unknown
+ } else {
+ Outcome::Failed
+ })
+ };
+ let result = ResultMessage {
+ request,
+ id: event.id.to_hex(),
+ outcome,
+ }
+ .sign(keys)?;
+ save(conn, &event.id.to_hex(), &result)?;
+ Ok(Some(result))
+}
diff --git a/desktop/src-tauri/src/managed_agents/placement/tests.rs b/desktop/src-tauri/src/managed_agents/placement/tests.rs
new file mode 100644
index 00000000000..acf98915e58
--- /dev/null
+++ b/desktop/src-tauri/src/managed_agents/placement/tests.rs
@@ -0,0 +1,284 @@
+use super::*;
+use buzz_core_pkg::desktop_lifecycle::{Outcome, ResultMessage};
+use nostr::Timestamp;
+fn event(keys: &Keys, host: &str, start: bool, stamp: u64) -> Event {
+ let target = StopTarget {
+ v: 1,
+ community: "wss://one.example".into(),
+ desktop: host.repeat(32),
+ agent: keys.public_key().to_hex(),
+ };
+ let event = if start {
+ Request {
+ target,
+ action: Action::Start,
+ observed: None,
+ }
+ .sign(keys)
+ .unwrap()
+ } else {
+ target.sign(keys).unwrap()
+ };
+ nostr::EventBuilder::new(event.kind, event.content)
+ .tags(event.tags)
+ .custom_created_at(Timestamp::from(stamp))
+ .sign_with_keys(keys)
+ .unwrap()
+}
+#[test]
+fn opposite_arrival_and_scoped_stops_converge_without_resurrection() {
+ let keys = Keys::generate();
+ let agent = keys.public_key().to_hex();
+ let events = [
+ event(&keys, "a", true, 1),
+ event(&keys, "b", true, 2),
+ event(&keys, "a", false, 3),
+ ];
+ for order in [vec![0, 1, 2], vec![2, 0, 1], vec![1, 2, 0]] {
+ let conn = Connection::open_in_memory().unwrap();
+ for i in order {
+ observe(&conn, &events[i], &keys, "wss://one.example").unwrap();
+ }
+ assert_eq!(
+ desired(&conn, &agent).unwrap(),
+ Some(("b".repeat(32), events[1].id.to_hex()))
+ );
+ assert!(blocked(&conn, &agent, &"a".repeat(32)).unwrap());
+ assert!(!blocked(&conn, &agent, &"b".repeat(32)).unwrap());
+ observe(
+ &conn,
+ &event(&keys, "b", false, 4),
+ &keys,
+ "wss://one.example",
+ )
+ .unwrap();
+ assert_eq!(desired(&conn, &agent).unwrap(), None);
+ assert!(blocked(&conn, &agent, &"b".repeat(32)).unwrap());
+ observe(&conn, &events[0], &keys, "wss://one.example").unwrap();
+ assert_eq!(desired(&conn, &agent).unwrap(), None);
+ }
+}
+#[test]
+fn same_second_lower_id_and_future_timestamp_are_authority() {
+ let keys = Keys::generate();
+ let conn = Connection::open_in_memory().unwrap();
+ let a = event(&keys, "a", true, 1000);
+ let b = event(&keys, "b", true, 1000);
+ for e in [&a, &b, &a] {
+ observe(&conn, e, &keys, "wss://one.example").unwrap();
+ }
+ let winner = if a.id < b.id { &a } else { &b };
+ assert_eq!(
+ desired(&conn, &keys.public_key().to_hex())
+ .unwrap()
+ .unwrap()
+ .1,
+ winner.id.to_hex()
+ );
+ observe(
+ &conn,
+ &event(&keys, "c", true, 999),
+ &keys,
+ "wss://one.example",
+ )
+ .unwrap();
+ assert_eq!(
+ desired(&conn, &keys.public_key().to_hex())
+ .unwrap()
+ .unwrap()
+ .1,
+ winner.id.to_hex()
+ );
+}
+#[test]
+fn consumption_survives_restart_and_result_eviction() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("journal.db");
+ let keys = Keys::generate();
+ let event = event(&keys, "a", true, 1);
+ let request = Request::read(&event, &keys, "wss://one.example").unwrap();
+ let mut conn = Connection::open(&path).unwrap();
+ assert!(admit(&conn, &event, &request).unwrap());
+ for i in 0..258 {
+ let result = ResultMessage {
+ request: request.clone(),
+ id: event.id.to_hex(),
+ outcome: Outcome::Unknown,
+ }
+ .sign(&keys)
+ .unwrap();
+ save(&mut conn, &i.to_string(), &result).unwrap();
+ }
+ drop(conn);
+ let conn = Connection::open(&path).unwrap();
+ assert!(!admit(&conn, &event, &request).unwrap());
+ assert!(saved(&conn, "0").unwrap().is_none());
+ assert!(admit(&conn, &super::tests::event(&keys, "a", true, 2), &request).unwrap());
+}
+
+#[test]
+fn receiver_consumes_before_effect_and_never_repeats_restart() {
+ let keys = Keys::generate();
+ let mut conn = Connection::open_in_memory().unwrap();
+ let start = event(&keys, "a", true, 10);
+ let start_request = Request::read(&start, &keys, "wss://one.example").unwrap();
+ observe(&conn, &start, &keys, "wss://one.example").unwrap();
+ let restart = Request {
+ action: Action::Restart,
+ observed: Some("f".repeat(64)),
+ ..start_request
+ }
+ .sign(&keys)
+ .unwrap();
+ let mut effects = 0;
+ let result = receive(
+ &mut conn,
+ &restart,
+ &keys,
+ "wss://one.example",
+ &"a".repeat(32),
+ true,
+ |conn, request| {
+ assert!(
+ !admit(conn, &restart, request).unwrap(),
+ "effect must see durable consumption"
+ );
+ effects += 1;
+ Ok(Outcome::Running)
+ },
+ )
+ .unwrap()
+ .unwrap();
+ let retry = receive(
+ &mut conn,
+ &restart,
+ &keys,
+ "wss://one.example",
+ &"a".repeat(32),
+ true,
+ |_, _| panic!("duplicate effect"),
+ )
+ .unwrap()
+ .unwrap();
+ assert_eq!(result, retry);
+ assert_eq!(effects, 1);
+ conn.execute("DELETE FROM desktop_lifecycle_results", [])
+ .unwrap();
+ let unknown = receive(
+ &mut conn,
+ &restart,
+ &keys,
+ "wss://one.example",
+ &"a".repeat(32),
+ true,
+ |_, _| panic!("evicted effect"),
+ )
+ .unwrap()
+ .unwrap();
+ assert_eq!(
+ ResultMessage::read(&unknown, &keys, &restart, "wss://one.example")
+ .unwrap()
+ .outcome,
+ Outcome::Unknown
+ );
+}
+
+#[test]
+fn receiver_rejects_wrong_owner_route_and_superseded_start() {
+ let keys = Keys::generate();
+ let mut conn = Connection::open_in_memory().unwrap();
+ let first = event(&keys, "a", true, 1);
+ let newer = event(&keys, "a", true, 2);
+ observe(&conn, &newer, &keys, "wss://one.example").unwrap();
+ assert!(receive(
+ &mut conn,
+ &first,
+ &Keys::generate(),
+ "wss://one.example",
+ &"a".repeat(32),
+ true,
+ |_, _| panic!("owner")
+ )
+ .is_err());
+ assert!(receive(
+ &mut conn,
+ &first,
+ &keys,
+ "wss://one.example",
+ &"b".repeat(32),
+ true,
+ |_, _| panic!("route")
+ )
+ .unwrap()
+ .is_none());
+ let result = receive(
+ &mut conn,
+ &first,
+ &keys,
+ "wss://one.example",
+ &"a".repeat(32),
+ true,
+ |_, _| panic!("stale Start"),
+ )
+ .unwrap()
+ .unwrap();
+ assert_eq!(
+ ResultMessage::read(&result, &keys, &first, "wss://one.example")
+ .unwrap()
+ .outcome,
+ Outcome::Unknown
+ );
+ let denied = receive(
+ &mut conn,
+ &newer,
+ &keys,
+ "wss://one.example",
+ &"a".repeat(32),
+ false,
+ |_, _| panic!("unowned"),
+ )
+ .unwrap()
+ .unwrap();
+ assert_eq!(
+ ResultMessage::read(&denied, &keys, &newer, "wss://one.example")
+ .unwrap()
+ .outcome,
+ Outcome::Failed
+ );
+}
+
+#[test]
+fn receiver_failure_is_saved_without_reinvoking_launch() {
+ let keys = Keys::generate();
+ let mut conn = Connection::open_in_memory().unwrap();
+ let start = event(&keys, "a", true, 1);
+ let result = receive(
+ &mut conn,
+ &start,
+ &keys,
+ "wss://one.example",
+ &"a".repeat(32),
+ true,
+ |_, _| Err("native error with private path".into()),
+ )
+ .unwrap()
+ .unwrap();
+ assert_eq!(
+ ResultMessage::read(&result, &keys, &start, "wss://one.example")
+ .unwrap()
+ .outcome,
+ Outcome::Failed
+ );
+ let retry = receive(
+ &mut conn,
+ &start,
+ &keys,
+ "wss://one.example",
+ &"a".repeat(32),
+ true,
+ |_, _| panic!("retry"),
+ )
+ .unwrap()
+ .unwrap();
+ assert_eq!(retry, result);
+}
From 0f2eb99feeebae4a5dae6fb13098aa509484abe9 Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 17:46:29 -0400
Subject: [PATCH 16/51] feat(desktop): introduce destination-local keyless
launch boundary
Signed-off-by: Logan Johnson
---
.../src/managed_agents/broker_launch.rs | 181 ++++++++++++++++++
desktop/src-tauri/src/managed_agents/mod.rs | 1 +
.../src-tauri/src/managed_agents/runtime.rs | 41 +++-
.../src/managed_agents/runtime_commands.rs | 33 +++-
4 files changed, 250 insertions(+), 6 deletions(-)
create mode 100644 desktop/src-tauri/src/managed_agents/broker_launch.rs
diff --git a/desktop/src-tauri/src/managed_agents/broker_launch.rs b/desktop/src-tauri/src/managed_agents/broker_launch.rs
new file mode 100644
index 00000000000..863e91e13d5
--- /dev/null
+++ b/desktop/src-tauri/src/managed_agents/broker_launch.rs
@@ -0,0 +1,181 @@
+//! Destination-local keyless launch boundary. Credentials never cross IPC/relay.
+//! Automatic issuance is not in the merged broker contract. Keep the production
+//! provider fail-closed until that host-owned adapter is supplied; do not mint a
+//! competing bearer token or fall back to exporting the agent key.
+use super::ManagedAgentRecord;
+use buzz_core_pkg::desktop_lifecycle::Outcome;
+use std::process::Command;
+
+pub(crate) struct LaunchScope<'a> {
+ pub owner: &'a str,
+ pub community: &'a str,
+ pub agent: &'a str,
+}
+
+/// A host-issued session, bound to this destination's independently resolved
+/// inputs. No Debug/Serialize: the bearer credential is not diagnostic data.
+pub(crate) struct BrokerSession {
+ owner: String,
+ community: String,
+ agent: String,
+ endpoint: String,
+ credential: zeroize::Zeroizing,
+ channels: Vec,
+ expires_at: u64,
+}
+
+impl BrokerSession {
+ /// Future host provisioning adapter calls this with its authenticated reply,
+ /// never values from an incoming lifecycle command or user environment.
+ #[allow(dead_code)] // consumed by the pending automatic-issuance host adapter
+ pub(crate) fn from_host(
+ scope: LaunchScope<'_>,
+ endpoint: String,
+ credential: String,
+ channels: Vec,
+ expires_at: u64,
+ ) -> Result {
+ let url = url::Url::parse(&endpoint).map_err(|_| "Invalid broker endpoint")?;
+ if !matches!(url.scheme(), "https" | "http")
+ || url.host_str().is_none()
+ || (url.scheme() == "http"
+ && !matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "[::1]")))
+ || !url.username().is_empty()
+ || url.password().is_some()
+ || url.query().is_some()
+ || url.fragment().is_some()
+ || credential.trim().is_empty()
+ || channels.is_empty()
+ || channels.len() > 256
+ || channels.iter().any(|c| uuid::Uuid::parse_str(c).is_err())
+ {
+ return Err("Invalid broker provisioning".into());
+ }
+ let session = Self {
+ owner: scope.owner.into(),
+ community: scope.community.into(),
+ agent: scope.agent.into(),
+ endpoint,
+ credential: zeroize::Zeroizing::new(credential),
+ channels,
+ expires_at,
+ };
+ session.validate(scope)?;
+ Ok(session)
+ }
+ pub(crate) fn validate(&self, scope: LaunchScope<'_>) -> Result<(), String> {
+ if self.owner != scope.owner
+ || self.community != scope.community
+ || self.agent != scope.agent
+ || self.expires_at <= nostr::Timestamp::now().as_secs().saturating_add(30)
+ {
+ return Err("Broker session expired or scope changed".into());
+ }
+ Ok(())
+ }
+ /// Applied last, after local user/provider configuration. Never let saved
+ /// environment turn a keyless launch into keyful authentication.
+ pub(crate) fn apply(
+ &self,
+ command: &mut Command,
+ scope: LaunchScope<'_>,
+ ) -> Result<(), String> {
+ self.validate(scope)?;
+ for key in [
+ "BUZZ_PRIVATE_KEY",
+ "NOSTR_PRIVATE_KEY",
+ "BUZZ_AUTH_TAG",
+ "BUZZ_API_TOKEN",
+ "BUZZ_ACP_PRIVATE_KEY",
+ "BUZZ_ACP_API_TOKEN",
+ "BUZZ_RELAY_URL",
+ "BUZZ_ACP_RELAY_URL",
+ "BUZZ_ACP_RESPOND_TO_ALLOWLIST",
+ "GIT_CONFIG_COUNT",
+ ] {
+ command.env_remove(key);
+ }
+ command
+ .env("BUZZ_AGENT_MODE", "broker")
+ .env("BUZZ_BROKER_URL", &self.endpoint)
+ .env("BUZZ_BROKER_CREDENTIAL", self.credential.as_str())
+ .env("BUZZ_BROKER_RELAY_URL", &self.community)
+ .env("BUZZ_ACP_AGENT_OWNER", &self.owner)
+ .env("BUZZ_ACP_CHANNELS", self.channels.join(","))
+ .env("BUZZ_ACP_RESPOND_TO", "owner-only")
+ .env("BUZZ_ACP_ALLOWED_RESPOND_TO", "owner-only");
+ Ok(())
+ }
+}
+
+/// Explicit external integration gate, not a fabricated successful launch.
+pub(crate) fn provision(
+ _scope: LaunchScope<'_>,
+ _record: &ManagedAgentRecord,
+) -> Result {
+ Err(Outcome::ProvisioningUnavailable)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ fn scope() -> LaunchScope<'static> {
+ LaunchScope {
+ owner: "owner",
+ community: "wss://one.example",
+ agent: "agent",
+ }
+ }
+ #[test]
+ fn scope_expiry_and_final_environment_are_enforced() {
+ let session = BrokerSession::from_host(
+ scope(),
+ "https://broker.example".into(),
+ "secret".into(),
+ vec![uuid::Uuid::new_v4().to_string()],
+ nostr::Timestamp::now().as_secs() + 300,
+ )
+ .unwrap();
+ assert!(session
+ .validate(LaunchScope {
+ community: "wss://other.example",
+ ..scope()
+ })
+ .is_err());
+ let mut command = Command::new("unused");
+ command
+ .env("BUZZ_PRIVATE_KEY", "must-not-escape")
+ .env("NOSTR_PRIVATE_KEY", "must-not-escape")
+ .env("BUZZ_AGENT_MODE", "local");
+ session.apply(&mut command, scope()).unwrap();
+ let env: std::collections::BTreeMap<_, _> = command.get_envs().collect();
+ assert_eq!(
+ env.get(std::ffi::OsStr::new("BUZZ_PRIVATE_KEY")),
+ Some(&None)
+ );
+ assert_eq!(
+ env.get(std::ffi::OsStr::new("NOSTR_PRIVATE_KEY")),
+ Some(&None)
+ );
+ assert_eq!(
+ env.get(std::ffi::OsStr::new("BUZZ_AGENT_MODE")),
+ Some(&Some(std::ffi::OsStr::new("broker")))
+ );
+ assert!(BrokerSession::from_host(
+ scope(),
+ "http://remote.example".into(),
+ "secret".into(),
+ vec![uuid::Uuid::new_v4().to_string()],
+ nostr::Timestamp::now().as_secs() + 300
+ )
+ .is_err());
+ assert!(BrokerSession::from_host(
+ scope(),
+ "https://broker.example".into(),
+ "secret".into(),
+ vec![uuid::Uuid::new_v4().to_string()],
+ 0
+ )
+ .is_err());
+ }
+}
diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs
index e859344e4c7..987e16a4f41 100644
--- a/desktop/src-tauri/src/managed_agents/mod.rs
+++ b/desktop/src-tauri/src/managed_agents/mod.rs
@@ -12,6 +12,7 @@ mod agent_description;
pub(crate) use agent_description::{effective_agent_description, record_effective_description};
mod backend;
pub(crate) mod bestie_assignment;
+pub(crate) mod broker_launch;
pub(crate) mod claude_config;
pub(crate) mod config_bridge;
pub(crate) mod custom_harnesses;
diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs
index 1eec6eee979..8d97fd1fff1 100644
--- a/desktop/src-tauri/src/managed_agents/runtime.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime.rs
@@ -452,10 +452,39 @@ pub fn spawn_agent_child(
owner_hex: Option<&str>,
replay_floor_unix: Option,
resume: Option<&super::remote_stop::ResumeTicket>,
+) -> Result {
+ spawn_agent_child_with_broker(
+ app,
+ record,
+ relay_url,
+ lazy,
+ owner_hex,
+ replay_floor_unix,
+ resume,
+ None,
+ )
+}
+
+#[allow(clippy::too_many_arguments)]
+pub(crate) fn spawn_agent_child_with_broker(
+ app: &AppHandle,
+ record: &ManagedAgentRecord,
+ relay_url: &str,
+ lazy: bool,
+ owner_hex: Option<&str>,
+ replay_floor_unix: Option,
+ resume: Option<&super::remote_stop::ResumeTicket>,
+ broker: Option<&super::broker_launch::BrokerSession>,
) -> Result {
let key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), relay_url)?;
super::remote_stop::check_launch(app, &key, owner_hex, resume)?;
- if let Some(error) = spawn_key_refusal(record) {
+ if let Some(session) = broker {
+ session.validate(super::broker_launch::LaunchScope {
+ owner: owner_hex.ok_or("Desktop owner unavailable")?,
+ community: relay_url,
+ agent: &record.pubkey,
+ })?;
+ } else if let Some(error) = spawn_key_refusal(record) {
return Err(error);
}
let runtime_key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), relay_url)?;
@@ -830,6 +859,16 @@ pub fn spawn_agent_child(
command.creation_flags(CREATE_NO_WINDOW);
}
+ if let Some(session) = broker {
+ session.apply(
+ &mut command,
+ super::broker_launch::LaunchScope {
+ owner: owner_hex.ok_or("Desktop owner unavailable")?,
+ community: relay_url,
+ agent: &record.pubkey,
+ },
+ )?;
+ }
let child = spawn_with_effort_proof(&mut command, effort).map_err(|error| {
format!(
"failed to spawn `{}` for agent {}: {error}",
diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs
index eacb7ba6bf6..665e3c5d426 100644
--- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs
@@ -5,10 +5,10 @@ use tauri::{AppHandle, Emitter, Manager};
use super::{
agent_readiness, current_instance_id, find_managed_agent_mut, load_global_agent_config,
load_managed_agents, load_personas, managed_agent_runtime_log_path, process_is_running,
- record_agent_command, resolve_effective_agent_env, save_managed_agents, spawn_agent_child,
- terminate_process, terminate_untracked_pair_runtime, write_agent_runtime_receipt,
- AgentReadiness, BackendKind, ManagedAgentPairRuntime, ManagedAgentRuntimeKey,
- ManagedAgentRuntimeLifecycle, ManagedAgentRuntimeReceipt, ManagedAgentRuntimeStatus,
+ record_agent_command, resolve_effective_agent_env, save_managed_agents, terminate_process,
+ terminate_untracked_pair_runtime, write_agent_runtime_receipt, AgentReadiness, BackendKind,
+ ManagedAgentPairRuntime, ManagedAgentRuntimeKey, ManagedAgentRuntimeLifecycle,
+ ManagedAgentRuntimeReceipt, ManagedAgentRuntimeStatus,
};
use crate::app_state::AppState;
@@ -261,6 +261,28 @@ fn start_pair(
.managed_agent_runtime_transition
.lock()
.map_err(|e| e.to_string())?;
+ start_pair_locked(
+ pubkey,
+ relay_url,
+ lazy,
+ expected_updated_at,
+ explicit_start,
+ None,
+ app.clone(),
+ )
+}
+
+// Caller owns the transition lock across admission, effect, receipt and result.
+pub(crate) fn start_pair_locked(
+ pubkey: String,
+ relay_url: String,
+ lazy: bool,
+ expected_updated_at: Option<&str>,
+ explicit_start: bool,
+ broker: Option<&super::broker_launch::BrokerSession>,
+ app: AppHandle,
+) -> Result {
+ let state = app.state::();
if state.shutdown_started.load(Ordering::Acquire) {
return Err("desktop shutdown has started".into());
}
@@ -305,7 +327,7 @@ fn start_pair(
} else {
None
};
- let mut process = spawn_agent_child(
+ let mut process = super::spawn_agent_child_with_broker(
&app,
record,
&key.relay_url,
@@ -313,6 +335,7 @@ fn start_pair(
owner.as_deref(),
None,
resume.as_ref(),
+ broker,
)?;
let now = crate::util::now_iso();
let receipt = ManagedAgentRuntimeReceipt {
From faee0caceae90956e759db5a031d13ba4dfcf577 Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 18:10:06 -0400
Subject: [PATCH 17/51] fix(multiverse): defer provisioning stub until receiver
slice
Signed-off-by: Logan Johnson
---
desktop/src-tauri/src/managed_agents/broker_launch.rs | 10 ----------
1 file changed, 10 deletions(-)
diff --git a/desktop/src-tauri/src/managed_agents/broker_launch.rs b/desktop/src-tauri/src/managed_agents/broker_launch.rs
index 863e91e13d5..b2d33051daf 100644
--- a/desktop/src-tauri/src/managed_agents/broker_launch.rs
+++ b/desktop/src-tauri/src/managed_agents/broker_launch.rs
@@ -2,8 +2,6 @@
//! Automatic issuance is not in the merged broker contract. Keep the production
//! provider fail-closed until that host-owned adapter is supplied; do not mint a
//! competing bearer token or fall back to exporting the agent key.
-use super::ManagedAgentRecord;
-use buzz_core_pkg::desktop_lifecycle::Outcome;
use std::process::Command;
pub(crate) struct LaunchScope<'a> {
@@ -108,14 +106,6 @@ impl BrokerSession {
}
}
-/// Explicit external integration gate, not a fabricated successful launch.
-pub(crate) fn provision(
- _scope: LaunchScope<'_>,
- _record: &ManagedAgentRecord,
-) -> Result {
- Err(Outcome::ProvisioningUnavailable)
-}
-
#[cfg(test)]
mod tests {
use super::*;
From a52c0537fcbe8be675af2ce052625b1e95375d67 Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 17:46:29 -0400
Subject: [PATCH 18/51] feat(desktop): receive authenticated Start and
current-host Restart
Signed-off-by: Logan Johnson
---
.../src/commands/desktop_lifecycle.rs | 343 ++++++++++++++++++
.../src-tauri/src/commands/desktop_stop.rs | 64 ++--
desktop/src-tauri/src/commands/mod.rs | 4 +-
desktop/src-tauri/src/lib.rs | 5 +
desktop/src-tauri/src/managed_agents/mod.rs | 1 +
.../src/managed_agents/remote_stop.rs | 16 +
6 files changed, 411 insertions(+), 22 deletions(-)
create mode 100644 desktop/src-tauri/src/commands/desktop_lifecycle.rs
diff --git a/desktop/src-tauri/src/commands/desktop_lifecycle.rs b/desktop/src-tauri/src/commands/desktop_lifecycle.rs
new file mode 100644
index 00000000000..a46ac7822a5
--- /dev/null
+++ b/desktop/src-tauri/src/commands/desktop_lifecycle.rs
@@ -0,0 +1,343 @@
+//! Trusted Desktop lifecycle adapter. Historical projection never launches.
+use super::{
+ desktop_profiles::scope,
+ desktop_stop::{local_id, owned_local},
+};
+use crate::{
+ app_state::AppState,
+ managed_agents::{self, broker_launch, placement, retention::open_retention_db},
+};
+use buzz_core_pkg::{
+ desktop_lifecycle::{Action, Outcome, Request, ResultMessage},
+ desktop_stop::StopTarget,
+};
+use nostr::{Event, JsonUtil};
+use rusqlite::{params, OptionalExtension};
+use tauri::{AppHandle, Manager};
+
+#[tauri::command]
+pub fn prepare_desktop_lifecycle(
+ app: AppHandle,
+ owner: String,
+ community: String,
+ desktop: String,
+ agent: String,
+ action: Action,
+ observed: Option,
+) -> Result {
+ let state = app.state::();
+ let scope = scope(&app, &state, &owner, &community)?;
+ let event = Request {
+ target: StopTarget {
+ v: 1,
+ community,
+ desktop,
+ agent,
+ },
+ action,
+ observed,
+ }
+ .sign(&scope.owner_keys)?;
+ let conn = open_retention_db(&scope.db_path)?;
+ conn.execute_batch("CREATE TABLE IF NOT EXISTS desktop_lifecycle_outgoing (slot INTEGER PRIMARY KEY CHECK(slot=1),raw TEXT NOT NULL)").map_err(|e|e.to_string())?;
+ conn.execute("INSERT INTO desktop_lifecycle_outgoing VALUES(1,?1) ON CONFLICT(slot) DO UPDATE SET raw=excluded.raw",[event.as_json()]).map_err(|e|e.to_string())?;
+ Ok(event)
+}
+
+/// Authenticated projection batches only; no Stop/Restart command replay. Stops
+/// caused by superseded placement reuse the ordinary local lifecycle owner.
+#[tauri::command]
+pub async fn observe_desktop_placement(
+ app: AppHandle,
+ owner: String,
+ community: String,
+ events: Vec,
+ reconcile: bool,
+) -> Result<(), String> {
+ if events.len() > 256 {
+ return Err("Too many placement events".into());
+ }
+ tokio::task::spawn_blocking(move || {
+ let state = app.state::();
+ let _transition = state
+ .managed_agent_runtime_transition
+ .lock()
+ .map_err(|e| e.to_string())?;
+ let scope = scope(&app, &state, &owner, &community)?;
+ let mut conn = open_retention_db(&scope.db_path)?;
+ let desktop = local_id(&mut conn, &scope)?;
+ let mut agents = std::collections::BTreeSet::new();
+ for event in events {
+ agents.insert(placement::observe(
+ &conn,
+ &event,
+ &scope.owner_keys,
+ &community,
+ )?);
+ }
+ if !reconcile {
+ return Ok(());
+ }
+ placement::schema(&conn)?;
+ let mut query = conn
+ .prepare("SELECT DISTINCT agent FROM desktop_placement")
+ .map_err(|e| e.to_string())?;
+ for row in query
+ .query_map([], |r| r.get::<_, String>(0))
+ .map_err(|e| e.to_string())?
+ {
+ agents.insert(row.map_err(|e| e.to_string())?);
+ }
+ for agent in agents {
+ if placement::blocked(&conn, &agent, &desktop)?
+ && owned_local(&app, &state, &owner, &agent)?
+ {
+ // Merely learning old Stop while no child exists performs no effect.
+ if generation(&app, &state, &agent, &community).map_or(true, |g| g.is_some()) {
+ managed_agents::stop_pair_locked(agent, community.clone(), app.clone())?;
+ }
+ }
+ }
+ Ok(())
+ })
+ .await
+ .map_err(|e| format!("Placement task failed: {e}"))?
+}
+
+#[tauri::command]
+pub fn read_desktop_placement(
+ app: AppHandle,
+ owner: String,
+ community: String,
+ agent: String,
+) -> Result, String> {
+ let state = app.state::();
+ let scope = scope(&app, &state, &owner, &community)?;
+ placement::latest_start(&open_retention_db(&scope.db_path)?, &agent)
+}
+
+fn generation(
+ app: &AppHandle,
+ state: &AppState,
+ agent: &str,
+ community: &str,
+) -> Result, String> {
+ let key = managed_agents::ManagedAgentRuntimeKey::new(agent, community)?;
+ let mut runtimes = state
+ .managed_agent_processes
+ .lock()
+ .map_err(|e| e.to_string())?;
+ if let Some(runtime) = runtimes.get_mut(&key) {
+ if runtime
+ .child
+ .try_wait()
+ .map_err(|e| e.to_string())?
+ .is_none()
+ {
+ return Ok(Some(runtime.start_nonce.clone()));
+ }
+ }
+ drop(runtimes);
+ let _store = state
+ .managed_agents_store_lock
+ .lock()
+ .map_err(|e| e.to_string())?;
+ let records = managed_agents::load_managed_agents(app)?;
+ let legacy = records
+ .iter()
+ .find(|r| r.pubkey == agent)
+ .and_then(|r| r.runtime_pid);
+ let dir = managed_agents::managed_agents_base_dir(app)?.join("agent-pids");
+ // A receipt may represent a surviving untracked child. Never turn a missing
+ // in-memory handle, unreadable receipt, or legacy live PID into Stopped.
+ if legacy.is_some_and(managed_agents::process_is_running)
+ || dir
+ .join(format!("{}.json", key.runtime_id()))
+ .try_exists()
+ .map_err(|e| e.to_string())?
+ || dir
+ .join(format!("{agent}.pid"))
+ .try_exists()
+ .map_err(|e| e.to_string())?
+ {
+ return Err("Local process state is untracked; use ordinary Desktop Stop".into());
+ }
+ Ok(None)
+}
+
+fn status(
+ app: &AppHandle,
+ conn: &rusqlite::Connection,
+ state: &AppState,
+ event: &Event,
+ request: &Request,
+) -> Result {
+ conn.execute_batch("CREATE TABLE IF NOT EXISTS desktop_status_generation (id TEXT PRIMARY KEY,nonce TEXT NOT NULL,observed INTEGER NOT NULL)").map_err(|e|e.to_string())?;
+ let Some(nonce) = generation(app, state, &request.target.agent, &request.target.community)?
+ else {
+ return Ok(Outcome::Stopped);
+ };
+ conn.execute(
+ "INSERT OR REPLACE INTO desktop_status_generation VALUES(?1,?2,?3)",
+ params![event.id.to_hex(), nonce, nostr::Timestamp::now().as_secs()],
+ )
+ .map_err(|e| e.to_string())?;
+ conn.execute("DELETE FROM desktop_status_generation WHERE rowid NOT IN (SELECT rowid FROM desktop_status_generation ORDER BY rowid DESC LIMIT 256)",[]).map_err(|e|e.to_string())?;
+ Ok(Outcome::Running)
+}
+fn current_observation(
+ app: &AppHandle,
+ conn: &rusqlite::Connection,
+ state: &AppState,
+ request: &Request,
+) -> Result {
+ let Some(id) = request.observed.as_deref() else {
+ return Ok(false);
+ };
+ // A request cannot create this local record; only a real Status observation can.
+ conn.execute_batch("CREATE TABLE IF NOT EXISTS desktop_status_generation (id TEXT PRIMARY KEY,nonce TEXT NOT NULL,observed INTEGER NOT NULL)").map_err(|e|e.to_string())?;
+ let saved: Option<(String, u64)> = conn
+ .query_row(
+ "SELECT nonce,observed FROM desktop_status_generation WHERE id=?1",
+ [id],
+ |r| Ok((r.get(0)?, r.get(1)?)),
+ )
+ .optional()
+ .map_err(|e| e.to_string())?;
+ Ok(match saved {
+ Some((nonce, stamp)) if nostr::Timestamp::now().as_secs().saturating_sub(stamp) <= 30 => {
+ generation(app, state, &request.target.agent, &request.target.community)?.as_deref()
+ == Some(nonce.as_str())
+ }
+ _ => false,
+ })
+}
+
+#[tauri::command]
+pub async fn receive_desktop_lifecycle(
+ app: AppHandle,
+ owner: String,
+ community: String,
+ event: Event,
+) -> Result, String> {
+ tokio::task::spawn_blocking(move || {
+ let state = app.state::();
+ let _transition = state
+ .managed_agent_runtime_transition
+ .lock()
+ .map_err(|e| e.to_string())?;
+ let scope = scope(&app, &state, &owner, &community)?;
+ let request = Request::read(&event, &scope.owner_keys, &community)?;
+ let mut conn = open_retention_db(&scope.db_path)?;
+ let desktop = local_id(&mut conn, &scope)?;
+ let owned = owned_local(&app, &state, &owner, &request.target.agent)?;
+ placement::receive(
+ &mut conn,
+ &event,
+ &scope.owner_keys,
+ &community,
+ &desktop,
+ owned,
+ |conn, request| {
+ if request.action == Action::Status {
+ status(&app, conn, &state, &event, request)
+ } else {
+ execute(&app, &state, conn, &owner, request)
+ }
+ },
+ )
+ })
+ .await
+ .map_err(|e| format!("Desktop lifecycle task failed: {e}"))?
+}
+
+fn execute(
+ app: &AppHandle,
+ state: &AppState,
+ conn: &rusqlite::Connection,
+ owner: &str,
+ request: &Request,
+) -> Result {
+ let target = &request.target;
+ if request.action == Action::Restart && !current_observation(app, conn, state, request)? {
+ return Ok(Outcome::Unknown);
+ }
+ if request.action == Action::Start
+ && generation(app, state, &target.agent, &target.community)?.is_some()
+ {
+ return Ok(Outcome::Running);
+ }
+ let record = {
+ let _store = state
+ .managed_agents_store_lock
+ .lock()
+ .map_err(|e| e.to_string())?;
+ managed_agents::load_managed_agents(app)?
+ .into_iter()
+ .find(|r| r.pubkey == target.agent)
+ .ok_or("Agent is not provisioned on this Desktop")?
+ };
+ // Provision before destructive Restart Stop. No supported session: leave the
+ // existing child running and return the precise non-secret missing capability.
+ let broker = match broker_launch::provision(
+ broker_launch::LaunchScope {
+ owner,
+ community: &target.community,
+ agent: &target.agent,
+ },
+ &record,
+ ) {
+ Ok(b) => b,
+ Err(outcome) => return Ok(outcome),
+ };
+ if request.action == Action::Restart
+ && managed_agents::stop_pair_locked(
+ target.agent.clone(),
+ target.community.clone(),
+ app.clone(),
+ )
+ .is_err()
+ {
+ return Ok(Outcome::Failed);
+ }
+ // Existing transition lock spans final intent check, ordinary launch and registration.
+ if placement::blocked(conn, &target.agent, &target.desktop)? {
+ return Ok(Outcome::Unknown);
+ }
+ match managed_agents::start_pair_locked(
+ target.agent.clone(),
+ target.community.clone(),
+ true,
+ None,
+ true,
+ Some(&broker),
+ app.clone(),
+ ) {
+ Ok(_) => Ok(Outcome::Running),
+ Err(_) => Ok(Outcome::Failed),
+ }
+}
+
+#[tauri::command]
+pub fn read_desktop_lifecycle_results(
+ app: AppHandle,
+ owner: String,
+ community: String,
+ request: Event,
+ events: Vec,
+) -> Result {
+ let state = app.state::();
+ let scope = scope(&app, &state, &owner, &community)?;
+ Request::read(&request, &scope.owner_keys, &community)?;
+ if events.len() > 16 {
+ return Err("Too many lifecycle results".into());
+ }
+ let mut outcome = Outcome::Unknown;
+ for event in events {
+ let result = ResultMessage::read(&event, &scope.owner_keys, &request, &community)?;
+ if result.outcome != Outcome::Unknown {
+ outcome = result.outcome;
+ }
+ }
+ Ok(outcome)
+}
diff --git a/desktop/src-tauri/src/commands/desktop_stop.rs b/desktop/src-tauri/src/commands/desktop_stop.rs
index 11ab7fe4e14..2c9623fb29a 100644
--- a/desktop/src-tauri/src/commands/desktop_stop.rs
+++ b/desktop/src-tauri/src/commands/desktop_stop.rs
@@ -12,7 +12,7 @@ use nostr::{Event, JsonUtil, PublicKey};
use serde_json::{json, Value};
use tauri::{AppHandle, Manager};
-fn local_id(
+pub(crate) fn local_id(
conn: &mut rusqlite::Connection,
scope: &managed_agents::retention::RetentionScope,
) -> Result {
@@ -72,31 +72,29 @@ pub async fn receive_desktop_stop(
let target = StopTarget::read(&event, &scope.owner_keys, &community)?;
let mut conn = open_retention_db(&scope.db_path)?;
let desktop = local_id(&mut conn, &scope)?;
+ managed_agents::placement::observe(&conn, &event, &scope.owner_keys, &community)?;
if desktop != target.desktop {
return Ok(None);
}
// Local possession alone is insufficient after an account switch:
// verify the stored agent's owner delegation against the request author.
- let owned = {
- let _store = state
- .managed_agents_store_lock
- .lock()
- .map_err(|e| e.to_string())?;
- let records = managed_agents::load_managed_agents(&app)?;
- records
- .iter()
- .find(|r| r.pubkey == target.agent)
- .is_some_and(|r| {
- r.backend == managed_agents::BackendKind::Local
- && r.auth_tag
- .as_deref()
- .and_then(|tag| {
- let key = PublicKey::from_hex(&target.agent).ok()?;
- buzz_sdk_pkg::nip_oa::verify_auth_tag(tag, &key).ok()
- })
- .is_some_and(|key| key.to_hex() == owner)
- })
- };
+ if let Some(raw) = remote_stop::saved_result(&conn, &event.id.to_hex())? {
+ let saved = Event::from_json(raw).map_err(|e| e.to_string())?;
+ StopResult::read(&saved, &scope.owner_keys, &event, &community)?;
+ return Ok(Some(saved));
+ }
+ if managed_agents::placement::desired(&conn, &target.agent)?
+ .is_some_and(|(host, _)| host == desktop)
+ {
+ return StopResult {
+ target,
+ request: event.id.to_hex(),
+ outcome: StopOutcome::Unknown,
+ }
+ .sign(&scope.owner_keys)
+ .map(Some);
+ }
+ let owned = owned_local(&app, &state, &owner, &target.agent)?;
remote_stop::receive(
&mut conn,
&event,
@@ -143,3 +141,27 @@ pub fn read_desktop_stop_results(
}
Ok(json!(outcome))
}
+
+/// Local possession/profile alone never establishes owner authority.
+pub(super) fn owned_local(
+ app: &AppHandle,
+ state: &AppState,
+ owner: &str,
+ agent: &str,
+) -> Result {
+ let _store = state
+ .managed_agents_store_lock
+ .lock()
+ .map_err(|e| e.to_string())?;
+ let records = managed_agents::load_managed_agents(app)?;
+ Ok(records.iter().find(|r| r.pubkey == agent).is_some_and(|r| {
+ r.backend == managed_agents::BackendKind::Local
+ && r.auth_tag
+ .as_deref()
+ .and_then(|tag| {
+ let key = PublicKey::from_hex(agent).ok()?;
+ buzz_sdk_pkg::nip_oa::verify_auth_tag(tag, &key).ok()
+ })
+ .is_some_and(|key| key.to_hex() == owner)
+ }))
+}
diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs
index 5a5c2566ac1..a0bbdd76c91 100644
--- a/desktop/src-tauri/src/commands/mod.rs
+++ b/desktop/src-tauri/src/commands/mod.rs
@@ -19,8 +19,9 @@ mod channel_window;
mod channels;
mod clipboard;
mod desktop_capabilities;
+mod desktop_lifecycle;
mod desktop_profiles;
-mod desktop_stop;
+pub(crate) mod desktop_stop;
mod dms;
mod engrams;
mod export_util;
@@ -96,6 +97,7 @@ pub use channel_window::*;
pub use channels::*;
pub use clipboard::*;
pub use desktop_capabilities::*;
+pub use desktop_lifecycle::*;
pub use desktop_profiles::*;
pub use desktop_stop::*;
pub use dms::*;
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 2eb43d10670..67b82af3dc1 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -554,6 +554,11 @@ pub fn run() {
get_identity,
prepare_desktop_profile,
prepare_desktop_stop,
+ prepare_desktop_lifecycle,
+ observe_desktop_placement,
+ read_desktop_placement,
+ receive_desktop_lifecycle,
+ read_desktop_lifecycle_results,
receive_desktop_stop,
read_desktop_stop_results,
read_desktop_profiles,
diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs
index 987e16a4f41..db259e80a71 100644
--- a/desktop/src-tauri/src/managed_agents/mod.rs
+++ b/desktop/src-tauri/src/managed_agents/mod.rs
@@ -28,6 +28,7 @@ pub(crate) mod parallelism;
mod persona_avatars;
pub(crate) mod persona_events;
mod personas;
+pub(crate) mod placement;
#[cfg(windows)]
mod process_lifecycle;
pub(crate) mod readiness;
diff --git a/desktop/src-tauri/src/managed_agents/remote_stop.rs b/desktop/src-tauri/src/managed_agents/remote_stop.rs
index 93e3e9b5adc..55d03649004 100644
--- a/desktop/src-tauri/src/managed_agents/remote_stop.rs
+++ b/desktop/src-tauri/src/managed_agents/remote_stop.rs
@@ -167,6 +167,22 @@ pub(crate) fn check_launch(
}
let conn = connection(app, key, ¤t_owner)?;
schema(&conn)?;
+ let scope = super::retention::RetentionScope {
+ db_path: scoped_retention_db_path(
+ &super::managed_agents_base_dir(app)?,
+ &key.relay_url,
+ ¤t_owner,
+ ),
+ relay_url: key.relay_url.clone(),
+ owner_keys: state.signing_keys()?,
+ };
+ let mut local_conn = connection(app, key, ¤t_owner)?;
+ let host = crate::commands::desktop_stop::local_id(&mut local_conn, &scope)?;
+ if super::placement::blocked(&conn, &key.pubkey, &host)?
+ && (resume.is_none() || super::placement::has_start(&conn, &key.pubkey)?)
+ {
+ return Err("This Desktop is no longer the selected running destination. Use explicit Start on this Desktop.".into());
+ }
let row: Option<(String, bool)> = conn
.query_row(
"SELECT event_id, blocked FROM desktop_stop_fence WHERE agent=?1",
From 30ce5b95f39855da2814889c0d6821d666573be3 Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 18:10:23 -0400
Subject: [PATCH 19/51] fix(multiverse): introduce provisioning stub with its
receiver
Signed-off-by: Logan Johnson
---
desktop/src-tauri/src/managed_agents/broker_launch.rs | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/desktop/src-tauri/src/managed_agents/broker_launch.rs b/desktop/src-tauri/src/managed_agents/broker_launch.rs
index b2d33051daf..863e91e13d5 100644
--- a/desktop/src-tauri/src/managed_agents/broker_launch.rs
+++ b/desktop/src-tauri/src/managed_agents/broker_launch.rs
@@ -2,6 +2,8 @@
//! Automatic issuance is not in the merged broker contract. Keep the production
//! provider fail-closed until that host-owned adapter is supplied; do not mint a
//! competing bearer token or fall back to exporting the agent key.
+use super::ManagedAgentRecord;
+use buzz_core_pkg::desktop_lifecycle::Outcome;
use std::process::Command;
pub(crate) struct LaunchScope<'a> {
@@ -106,6 +108,14 @@ impl BrokerSession {
}
}
+/// Explicit external integration gate, not a fabricated successful launch.
+pub(crate) fn provision(
+ _scope: LaunchScope<'_>,
+ _record: &ManagedAgentRecord,
+) -> Result {
+ Err(Outcome::ProvisioningUnavailable)
+}
+
#[cfg(test)]
mod tests {
use super::*;
From 8b26bb20b587e98457f001773f6b98afe7562335 Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 17:46:29 -0400
Subject: [PATCH 20/51] feat(desktop): coordinate failure-final Move and exact
lifecycle retry
Signed-off-by: Logan Johnson
---
.../features/agents/desktopLifecycle.test.mjs | 240 +++++++++++++
.../src/features/agents/desktopLifecycle.ts | 334 ++++++++++++++++++
desktop/src/shared/api/relayClientSession.ts | 5 +
3 files changed, 579 insertions(+)
create mode 100644 desktop/src/features/agents/desktopLifecycle.test.mjs
create mode 100644 desktop/src/features/agents/desktopLifecycle.ts
diff --git a/desktop/src/features/agents/desktopLifecycle.test.mjs b/desktop/src/features/agents/desktopLifecycle.test.mjs
new file mode 100644
index 00000000000..b15daccfcff
--- /dev/null
+++ b/desktop/src/features/agents/desktopLifecycle.test.mjs
@@ -0,0 +1,240 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { lifecycleClient, receiveLifecycle } from "./desktopLifecycle.ts";
+const scope = { owner: "owner", community: "wss://one.example" };
+const tick = () => new Promise((resolve) => setImmediate(resolve));
+function fixture() {
+ let epoch = 0,
+ connection = 1,
+ active = true,
+ live;
+ let placement = ["source", "selection"],
+ stopOutcome = "stopped";
+ let lifecycleOutcome = "running",
+ history = [],
+ ackLost = false;
+ const prepared = [],
+ sent = [],
+ calls = [],
+ errors = [];
+ const ipc = async (command, args) => {
+ assert.equal(args.owner, scope.owner);
+ assert.equal(args.community, scope.community);
+ calls.push([command, args]);
+ if (command === "observe_desktop_placement") return;
+ if (command === "read_desktop_placement") return placement;
+ if (
+ command === "prepare_desktop_lifecycle" ||
+ command === "prepare_desktop_stop"
+ ) {
+ const request = {
+ id: `request-${prepared.length}`,
+ kind: command.endsWith("_stop") ? 50180 : 50182,
+ ...args,
+ };
+ prepared.push(request);
+ return request;
+ }
+ if (command === "read_desktop_lifecycle_results")
+ return args.request.action === "status" ? "running" : lifecycleOutcome;
+ if (command === "read_desktop_stop_results") return stopOutcome;
+ if (command === "receive_desktop_lifecycle")
+ return { id: "result", kind: 50183 };
+ throw Error(command);
+ };
+ const relay = {
+ getSessionEpoch: () => epoch,
+ getConnectionGeneration: () => connection,
+ fetchEvents: async (filter) =>
+ filter.kinds.includes(50182) ? history : [],
+ publishEvent: async (event, _timeout, _failure, check) => {
+ check();
+ sent.push(event);
+ if (ackLost) throw Error("ACK lost");
+ },
+ subscribeLive: async (filter, callback) => {
+ assert.deepEqual(filter, {
+ kinds: [50182, 50180],
+ authors: [scope.owner],
+ limit: 0,
+ });
+ live = callback;
+ return () => {
+ live = undefined;
+ };
+ },
+ };
+ return {
+ ipc,
+ relay,
+ prepared,
+ sent,
+ calls,
+ errors,
+ client: () => lifecycleClient(scope, () => active, ipc, relay),
+ changeScope: () => {
+ epoch++;
+ },
+ disconnect: () => {
+ connection++;
+ },
+ unmount: () => {
+ active = false;
+ },
+ stop: (value) => {
+ stopOutcome = value;
+ },
+ outcome: (value) => {
+ lifecycleOutcome = value;
+ },
+ place: (value) => {
+ placement = value;
+ },
+ history: (value) => {
+ history = value;
+ },
+ loseAck: () => {
+ ackLost = true;
+ },
+ deliver: (event) => live?.(event),
+ };
+}
+
+test("lost ACK/result permits explicit exact-byte retry, not a fresh Start", async () => {
+ const f = fixture(),
+ client = f.client();
+ const request = await client.start("destination", "agent");
+ f.loseAck();
+ f.outcome("unknown");
+ assert.equal(await client.send(request, 0), "unknown");
+ f.outcome("running");
+ assert.equal(await client.send(request, 1), "running");
+ assert.equal(f.prepared.length, 1);
+ assert.deepEqual(f.sent, [request, request]);
+ assert.equal(f.sent[0], f.sent[1]);
+ assert.equal(
+ f.calls.filter(([c]) => c === "receive_desktop_lifecycle").length,
+ 0,
+ );
+});
+
+for (const interruption of ["changeScope", "disconnect", "unmount"]) {
+ test(`${interruption} during Stop cancels Move before destination prepare or send`, async () => {
+ const f = fixture(),
+ client = f.client();
+ const publish = f.relay.publishEvent;
+ f.relay.publishEvent = async (...args) => {
+ await publish(...args);
+ if (args[0].kind === 50180) f[interruption]();
+ };
+ await assert.rejects(
+ client.move("agent", "destination", ["source"], () => {}),
+ /scope changed/,
+ );
+ assert.equal(f.prepared.filter((r) => r.action === "start").length, 0);
+ });
+}
+
+test("failed Stop is final even if a successful outcome appears later", async () => {
+ const f = fixture();
+ f.stop("failed");
+ await assert.rejects(
+ f.client().move("agent", "destination", ["source"], () => {}),
+ /will not continue later/,
+ );
+ f.stop("stopped");
+ await tick();
+ assert.equal(f.prepared.filter((r) => r.action === "start").length, 0);
+});
+
+test("unconfirmed Stop exhausts polling without storing any future Start", async () => {
+ const f = fixture();
+ f.stop("unknown");
+ const timer = globalThis.setTimeout;
+ globalThis.setTimeout = (fn) => {
+ queueMicrotask(fn);
+ return 0;
+ };
+ try {
+ await assert.rejects(
+ f.client().move("agent", "destination", ["source"], () => {}),
+ /Could not confirm source Stop/,
+ );
+ assert.equal(f.prepared.filter((r) => r.action === "start").length, 0);
+ } finally {
+ globalThis.setTimeout = timer;
+ }
+});
+
+test("Move dispatches Start only after Stop success and unchanged placement", async () => {
+ const f = fixture();
+ assert.equal(
+ await f.client().move("agent", "destination", ["source"], () => {}),
+ "running",
+ );
+ assert.deepEqual(
+ f.sent.map((r) => r.action ?? "stop"),
+ ["status", "stop", "start"],
+ );
+ assert.equal(f.sent.at(-1).desktop, "destination");
+ const stopRead = f.calls.findIndex(
+ ([c]) => c === "read_desktop_stop_results",
+ );
+ const startPrepare = f.calls.findIndex(
+ ([c, a]) => c === "prepare_desktop_lifecycle" && a.action === "start",
+ );
+ assert.ok(stopRead < startPrepare);
+});
+
+test("another Desktop's new placement supersedes an in-flight Move", async () => {
+ const f = fixture(),
+ publish = f.relay.publishEvent;
+ f.relay.publishEvent = async (...args) => {
+ await publish(...args);
+ if (args[0].kind === 50180) f.place(["third", "new-selection"]);
+ };
+ await assert.rejects(
+ f.client().move("agent", "destination", ["source"], () => {}),
+ /Placement changed/,
+ );
+ assert.equal(f.prepared.filter((r) => r.action === "start").length, 0);
+});
+
+test("Restart resolves current host and binds its Status observation, not destination", async () => {
+ const f = fixture();
+ const request = await f.client().restart("agent", ["source", "other"]);
+ assert.equal(request.desktop, "source");
+ assert.equal(request.observed, f.prepared[0].id);
+ assert.equal(request.action, "restart");
+});
+
+test("receiver projects history without executing it and invalidates live work on disconnect", async () => {
+ const f = fixture();
+ f.history([{ id: "historical", kind: 50182 }]);
+ const close = await receiveLifecycle(
+ scope,
+ () => true,
+ (e) => f.errors.push(e),
+ f.ipc,
+ f.relay,
+ );
+ assert.equal(
+ f.calls.filter(([c]) => c === "receive_desktop_lifecycle").length,
+ 0,
+ );
+ f.deliver({ id: "live", kind: 50182 });
+ await tick();
+ assert.equal(
+ f.calls.filter(([c]) => c === "receive_desktop_lifecycle").length,
+ 1,
+ );
+ f.disconnect();
+ f.deliver({ id: "late", kind: 50182 });
+ await tick();
+ assert.equal(
+ f.calls.filter(([c]) => c === "receive_desktop_lifecycle").length,
+ 1,
+ );
+ assert.equal(f.errors.length, 1);
+ close();
+});
diff --git a/desktop/src/features/agents/desktopLifecycle.ts b/desktop/src/features/agents/desktopLifecycle.ts
new file mode 100644
index 00000000000..7af772aa181
--- /dev/null
+++ b/desktop/src/features/agents/desktopLifecycle.ts
@@ -0,0 +1,334 @@
+import { invoke } from "@tauri-apps/api/core";
+import { relayClient } from "@/shared/api/relayClient";
+import type { RelayEvent } from "@/shared/api/types";
+import type { DesktopScope } from "./desktopList";
+import {
+ DESKTOP_STOP,
+ prepareStop,
+ readStopOutcome,
+ sendStop,
+} from "./desktopStop";
+
+export const DESKTOP_LIFECYCLE = 50182;
+export const DESKTOP_LIFECYCLE_RESULT = 50183;
+export type LifecycleAction = "start" | "restart" | "status";
+export type LifecycleOutcome =
+ | "running"
+ | "stopped"
+ | "provisioning_unavailable"
+ | "failed"
+ | "unknown";
+export type CurrentHost = { desktop: string; observation: string };
+
+/** Captures identity and connection generation across every asynchronous step. */
+export function lifecycleClient(
+ scope: DesktopScope,
+ active: () => boolean,
+ ipc = invoke,
+ relay = relayClient,
+) {
+ const epoch = relay.getSessionEpoch();
+ const connection = relay.getConnectionGeneration();
+ const check = () => {
+ if (
+ !active() ||
+ relay.getSessionEpoch() !== epoch ||
+ relay.getConnectionGeneration() !== connection
+ )
+ throw new Error("Desktop lifecycle scope changed");
+ };
+ const prepare = async (
+ desktop: string,
+ agent: string,
+ action: LifecycleAction,
+ observed: string | null = null,
+ ) => {
+ check();
+ const request = await ipc("prepare_desktop_lifecycle", {
+ ...scope,
+ desktop,
+ agent,
+ action,
+ observed,
+ });
+ check();
+ return request;
+ };
+ const read = async (request: RelayEvent) => {
+ check();
+ const events = await relay.fetchEvents({
+ kinds: [DESKTOP_LIFECYCLE_RESULT],
+ authors: [scope.owner],
+ "#e": [request.id],
+ limit: 16,
+ });
+ check();
+ const outcome = await ipc(
+ "read_desktop_lifecycle_results",
+ { ...scope, request, events },
+ );
+ check();
+ return outcome;
+ };
+ const send = async (
+ request: RelayEvent,
+ attempts = 15,
+ ): Promise => {
+ check();
+ try {
+ await relay.publishEvent(
+ request,
+ "Delivery unconfirmed",
+ "Delivery failed",
+ check,
+ );
+ } catch {
+ check(); /* Lost ACK may still have a signed result. */
+ }
+ for (let i = 0; i < attempts; i++) {
+ check();
+ const outcome = await read(request);
+ check();
+ if (outcome !== "unknown") return outcome;
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ }
+ return "unknown";
+ };
+ const sync = async () => {
+ let until: number | undefined;
+ let before_id: string | undefined;
+ for (let page = 0; page < 64; page++) {
+ check();
+ const events = await relay.fetchEvents({
+ kinds: [DESKTOP_STOP, DESKTOP_LIFECYCLE],
+ authors: [scope.owner],
+ limit: 256,
+ until,
+ before_id,
+ });
+ check();
+ // No effects while a partial page could still hide a dominating Start.
+ await ipc("observe_desktop_placement", {
+ ...scope,
+ events,
+ reconcile: false,
+ });
+ check();
+ if (events.length < 256) {
+ await ipc("observe_desktop_placement", {
+ ...scope,
+ events: [],
+ reconcile: true,
+ });
+ check();
+ return;
+ }
+ const last = events.at(-1);
+ if (!last || last.id === before_id)
+ throw new Error("Placement history cursor did not advance");
+ until = last.created_at;
+ before_id = last.id;
+ }
+ throw new Error(
+ "Placement history is incomplete; no launch was dispatched",
+ );
+ };
+ const current = async (
+ agent: string,
+ desktops: string[],
+ ): Promise => {
+ await sync();
+ check();
+ const desired = await ipc<[string, string] | null>(
+ "read_desktop_placement",
+ { ...scope, agent },
+ );
+ check();
+ // Probe actual native state, never infer current from last-heard/profile.
+ const candidates = desired ? [desired[0]] : [...new Set(desktops)];
+ if (!candidates.length || candidates.length > 32)
+ throw new Error("Current Desktop is unknown");
+ const observations = await Promise.all(
+ candidates.map(async (desktop) => {
+ const request = await prepare(desktop, agent, "status");
+ return {
+ desktop,
+ observation: request.id,
+ outcome: await send(request, 3),
+ };
+ }),
+ );
+ check();
+ const running = observations.filter((o) => o.outcome === "running");
+ if (
+ running.length !== 1 ||
+ observations.some(
+ (o) => o.outcome !== "running" && o.outcome !== "stopped",
+ )
+ )
+ throw new Error(
+ "Current Desktop is unknown or ambiguous; choose explicit Start instead",
+ );
+ return running[0];
+ };
+ const start = async (desktop: string, agent: string) => {
+ await sync();
+ return prepare(desktop, agent, "start");
+ };
+ const restart = async (agent: string, desktops: string[]) => {
+ const host = await current(agent, desktops);
+ check();
+ return prepare(host.desktop, agent, "restart", host.observation);
+ };
+ /** Failed/unconfirmed Move is terminal in this invocation. No saved future
+ * Start, background callback, reopen replay or retry of a failed Move. */
+ const move = async (
+ agent: string,
+ destination: string,
+ desktops: string[],
+ onStage: (stage: string) => void,
+ ): Promise => {
+ const host = await current(agent, desktops);
+ check();
+ if (host.desktop === destination)
+ throw new Error("Agent is already on the selected Desktop");
+ const before = await ipc<[string, string] | null>(
+ "read_desktop_placement",
+ { ...scope, agent },
+ );
+ check();
+ const stop = await prepareStop(
+ scope,
+ host.desktop,
+ agent,
+ active,
+ ipc,
+ relay,
+ );
+ check();
+ onStage(
+ "Waiting for source Desktop Stop; destination has not been started.",
+ );
+ try {
+ await sendStop(scope, stop, active, relay);
+ } catch {
+ check();
+ }
+ let stopped = false;
+ for (let i = 0; i < 15; i++) {
+ const result = await readStopOutcome(scope, stop, active, ipc, relay);
+ check();
+ if (result === "failed") break;
+ if (result === "stopped") {
+ stopped = true;
+ break;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ }
+ if (!stopped)
+ throw new Error(
+ "Could not confirm source Stop; destination was not started. This Move will not continue later.",
+ );
+ await sync();
+ check();
+ const after = await ipc<[string, string] | null>("read_desktop_placement", {
+ ...scope,
+ agent,
+ });
+ check();
+ // Stop may clear source, but another device's new placement must win.
+ if (after && (!before || after[1] !== before[1]))
+ throw new Error(
+ "Placement changed during Move; destination was not started",
+ );
+ onStage("Source Stop confirmed. Requesting destination Start.");
+ const request = await prepare(destination, agent, "start");
+ check();
+ return send(request);
+ };
+ return { check, prepare, read, send, sync, current, start, restart, move };
+}
+
+/** Subscribe first, then project history; live commands wait for complete
+ * initialization. Reconnect gets a new client epoch and never replays history. */
+export async function receiveLifecycle(
+ scope: DesktopScope,
+ active: () => boolean,
+ onError: (message: string) => void,
+ ipc = invoke,
+ relay = relayClient,
+) {
+ let client: ReturnType;
+ let initialized: () => void = () => {};
+ const ready = new Promise((resolve) => {
+ initialized = resolve;
+ });
+ let chain = Promise.resolve();
+ let pending = 0;
+ const close = await relay.subscribeLive(
+ {
+ kinds: [DESKTOP_LIFECYCLE, DESKTOP_STOP],
+ authors: [scope.owner],
+ limit: 0,
+ },
+ (event) => {
+ if (!active()) return;
+ if (pending >= 16) {
+ onError("Desktop lifecycle receiver is busy; outcome is unconfirmed.");
+ return;
+ }
+ pending++;
+ chain = chain
+ .then(async () => {
+ await ready;
+ client.check();
+ await ipc("observe_desktop_placement", {
+ ...scope,
+ events: [event],
+ reconcile: true,
+ });
+ client.check();
+ const result = await ipc(
+ event.kind === DESKTOP_STOP
+ ? "receive_desktop_stop"
+ : "receive_desktop_lifecycle",
+ { ...scope, event },
+ );
+ client.check();
+ if (result)
+ await relay.publishEvent(
+ result,
+ "Result delivery unconfirmed",
+ "Result delivery failed",
+ client.check,
+ );
+ })
+ .catch(() => {
+ if (active())
+ onError(
+ "Desktop lifecycle result is unconfirmed. No automatic operation retry.",
+ );
+ })
+ .finally(() => {
+ pending--;
+ });
+ },
+ (readiness) => {
+ if (active() && readiness !== "eose")
+ onError("Desktop lifecycle receiver is unavailable.");
+ },
+ );
+ client = lifecycleClient(scope, active, ipc, relay);
+ try {
+ await client.sync();
+ client.check();
+ initialized();
+ } catch (error) {
+ close();
+ // Release queued callbacks into a permanently invalidated client.
+ client = lifecycleClient(scope, () => false, ipc, relay);
+ initialized();
+ throw error;
+ }
+ return close;
+}
diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts
index 3839d01c7e5..2f94a65bf9b 100644
--- a/desktop/src/shared/api/relayClientSession.ts
+++ b/desktop/src/shared/api/relayClientSession.ts
@@ -115,6 +115,11 @@ export class RelayClient {
getSessionEpoch() {
return this.sessionEpoch;
}
+
+ /** Invalidates one-shot lifecycle coordinators on a transport interruption. */
+ getConnectionGeneration() {
+ return this.connectionGeneration;
+ }
disconnect() {
const error = new Error("Relay disconnected for community switch.");
From def4f237768fcdde83557ac76e5cb2e694bd732d Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 17:46:29 -0400
Subject: [PATCH 21/51] feat(desktop): mount Start Restart and Move controls
Signed-off-by: Logan Johnson
---
.../ui/DesktopLifecycleControl.test.mjs | 133 +++++++++++
.../agents/ui/DesktopLifecycleControl.tsx | 226 ++++++++++++++++++
.../src/features/agents/ui/KnownDesktops.tsx | 18 +-
3 files changed, 375 insertions(+), 2 deletions(-)
create mode 100644 desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs
create mode 100644 desktop/src/features/agents/ui/DesktopLifecycleControl.tsx
diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs
new file mode 100644
index 00000000000..d6919521225
--- /dev/null
+++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs
@@ -0,0 +1,133 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import React from "react";
+import { JSDOM } from "jsdom";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { DesktopLifecycleControl } from "./DesktopLifecycleControl.tsx";
+import { relayClient } from "../../../shared/api/relayClient.ts";
+
+test("mounted Start exposes unavailable provisioning and exact retry; Restart resolves source", async () => {
+ const dom = new JSDOM("
", {
+ url: "https://desktop.test",
+ });
+ Object.assign(globalThis, {
+ window: dom.window,
+ document: dom.window.document,
+ localStorage: dom.window.localStorage,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ });
+ const { createRoot } = await import("react-dom/client");
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: Infinity } },
+ });
+ client.setQueryData(
+ ["relay-agents"],
+ [
+ { pubkey: "agent", name: "Owned agent", ownerPubkey: "owner" },
+ { pubkey: "foreign", name: "Foreign agent", ownerPubkey: "other" },
+ ],
+ );
+ const scope = { owner: "owner", community: "wss://one.example" };
+ const originals = {
+ fetch: relayClient.fetchEvents,
+ publish: relayClient.publishEvent,
+ };
+ const prepared = [],
+ sent = [];
+ let stop = "failed";
+ window.__TAURI_INTERNALS__ = {
+ invoke: async (command, args) => {
+ assert.equal(args.owner, scope.owner);
+ assert.equal(args.community, scope.community);
+ if (command === "observe_desktop_placement") return;
+ if (command === "read_desktop_placement") return ["source", "selection"];
+ if (
+ command === "prepare_desktop_lifecycle" ||
+ command === "prepare_desktop_stop"
+ ) {
+ const request = {
+ id: `request-${prepared.length}`,
+ kind: command.endsWith("_stop") ? 50180 : 50182,
+ ...args,
+ };
+ prepared.push(request);
+ return request;
+ }
+ if (command === "read_desktop_lifecycle_results")
+ return args.request.action === "status"
+ ? "running"
+ : "provisioning_unavailable";
+ if (command === "read_desktop_stop_results") return stop;
+ throw Error(command);
+ },
+ };
+ relayClient.fetchEvents = async () => [];
+ relayClient.publishEvent = async (event, _timeout, _failure, check) => {
+ check();
+ sent.push(event);
+ };
+ const root = createRoot(document.getElementById("root"));
+ const click = (text) =>
+ React.act(async () =>
+ [...document.querySelectorAll("button")]
+ .find((b) => b.textContent === text)
+ .click(),
+ );
+ const select = (label, value) =>
+ React.act(async () => {
+ const element = document.querySelector(`select[aria-label="${label}"]`);
+ element.value = value;
+ element.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
+ });
+ try {
+ await React.act(async () =>
+ root.render(
+ React.createElement(
+ QueryClientProvider,
+ { client },
+ React.createElement(DesktopLifecycleControl, {
+ scope,
+ desktops: [
+ { id: "source", name: "Source" },
+ { id: "destination", name: "Destination" },
+ ],
+ }),
+ ),
+ ),
+ );
+ assert.doesNotMatch(document.body.textContent, /Foreign agent/);
+ await select("Agent to place", "agent");
+ await select("Destination Desktop", "destination");
+ await click("Start on destination");
+ assert.match(
+ document.body.textContent,
+ /keyless launch provisioning is unavailable/,
+ );
+ assert.equal(prepared[0].desktop, "destination");
+ await click("Retry same request");
+ assert.equal(prepared.length, 1);
+ assert.equal(sent[0], sent[1]);
+ await click("Restart on current Desktop");
+ const restart = prepared.at(-1);
+ assert.equal(
+ restart.desktop,
+ "source",
+ "destination picker must not redirect Restart",
+ );
+ assert.equal(restart.action, "restart");
+ assert.equal(restart.observed, prepared.at(-2).id);
+ await click("Move to destination");
+ assert.match(document.body.textContent, /destination was not started/);
+ const count = prepared.length;
+ stop = "stopped";
+ await React.act(async () => {});
+ assert.equal(prepared.length, count, "late Stop cannot resume failed Move");
+ assert.doesNotMatch(document.body.textContent, /Retry same request/);
+ } finally {
+ await React.act(async () => root.unmount());
+ client.clear();
+ relayClient.fetchEvents = originals.fetch;
+ relayClient.publishEvent = originals.publish;
+ dom.window.close();
+ }
+});
diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx
new file mode 100644
index 00000000000..5baffa6f4cb
--- /dev/null
+++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx
@@ -0,0 +1,226 @@
+import { useEffect, useRef, useState } from "react";
+import { Button } from "@/shared/ui/button";
+import type { RelayEvent } from "@/shared/api/types";
+import type { DesktopRow, DesktopScope } from "../desktopList";
+import {
+ lifecycleClient,
+ receiveLifecycle,
+ type LifecycleOutcome,
+} from "../desktopLifecycle";
+import { useRelayAgentsQuery } from "../hooks";
+
+export function DesktopLifecycleReceiver({
+ scope,
+}: {
+ scope: DesktopScope | null;
+}) {
+ const [error, setError] = useState("");
+ const { owner, community } = scope ?? {};
+ useEffect(() => {
+ if (!owner || !community) return;
+ let active = true;
+ let close: (() => void) | undefined;
+ setError("");
+ void receiveLifecycle({ owner, community }, () => active, setError)
+ .then((fn) => {
+ if (active) close = fn;
+ else fn();
+ })
+ .catch(() => {
+ if (active) setError("Desktop lifecycle receiver is unavailable.");
+ });
+ return () => {
+ active = false;
+ close?.();
+ };
+ }, [owner, community]);
+ return error ? (
+
+ {error}
+
+ ) : null;
+}
+function message(outcome: LifecycleOutcome) {
+ switch (outcome) {
+ case "running":
+ return "Desktop confirmed a running local process. This does not prove model readiness.";
+ case "provisioning_unavailable":
+ return "Destination keyless launch provisioning is unavailable. No new process was started.";
+ case "stopped":
+ return "Desktop reports the agent stopped.";
+ case "failed":
+ return "Desktop rejected or failed the operation. No successful launch was confirmed.";
+ default:
+ return "Operation unconfirmed. A dispatched effect may still finish; no automatic retry will run.";
+ }
+}
+/** Start/Move choose destination; Restart has no host picker and resolves actual current state. */
+export function DesktopLifecycleControl({
+ scope,
+ desktops,
+}: {
+ scope: DesktopScope;
+ desktops: DesktopRow[];
+}) {
+ const agents = useRelayAgentsQuery();
+ const [agent, setAgent] = useState("");
+ const [destination, setDestination] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [status, setStatus] = useState("");
+ const [request, setRequest] = useState(null);
+ const active = useRef(true);
+ const generation = useRef(0);
+ useEffect(() => {
+ active.current = true;
+ return () => {
+ active.current = false;
+ generation.current++;
+ };
+ }, []);
+ const run = async (action: "start" | "restart" | "move" | "retry") => {
+ const token = ++generation.current;
+ const valid = () => active.current && generation.current === token;
+ const client = lifecycleClient(scope, valid);
+ setBusy(true);
+ setStatus("Checking authenticated Desktop state…");
+ if (action !== "retry") setRequest(null);
+ try {
+ if (action === "move") {
+ const outcome = await client.move(
+ agent,
+ destination,
+ desktops.map((d) => d.id),
+ (stage) => {
+ if (valid()) setStatus(stage);
+ },
+ );
+ client.check();
+ setStatus(message(outcome));
+ } else {
+ const next =
+ action === "retry"
+ ? request
+ : action === "start"
+ ? await client.start(destination, agent)
+ : await client.restart(
+ agent,
+ desktops.map((d) => d.id),
+ );
+ if (!next) throw new Error("No request to retry");
+ client.check();
+ setRequest(next);
+ setStatus("Request sent. Waiting for the Desktop’s actual result…");
+ const outcome = await client.send(next);
+ client.check();
+ setStatus(message(outcome));
+ }
+ } catch (error) {
+ if (valid())
+ setStatus(
+ error instanceof Error ? error.message : "Operation unconfirmed",
+ );
+ } finally {
+ if (valid()) setBusy(false);
+ }
+ };
+ const reset = () => {
+ setRequest(null);
+ setStatus("");
+ };
+ return (
+
+ Start, restart, or move an agent
+
+ Agent
+ {
+ setAgent(e.target.value);
+ reset();
+ }}
+ className="ml-2 rounded border bg-background p-1"
+ >
+ Choose your agent
+ {(agents.data ?? [])
+ .filter((a) => a.ownerPubkey === scope.owner)
+ .map((a) => (
+
+ {a.name}
+
+ ))}
+
+
+ void run("restart")}
+ >
+ Restart on current Desktop
+
+
+ Destination for Start or Move
+ {
+ setDestination(e.target.value);
+ reset();
+ }}
+ className="ml-2 rounded border bg-background p-1"
+ >
+ Choose a Desktop
+ {desktops.map((d) => (
+
+ {d.name}
+
+ ))}
+
+
+
+ Start may overlap with an agent still running elsewhere until that
+ Desktop reconnects. Move starts the destination only after source Stop
+ is confirmed. Nothing transfers files, configuration, or keys.
+
+
+ void run("start")}
+ >
+ Start on destination
+
+ void run("move")}
+ >
+ Move to destination
+
+ {request && (
+ void run("retry")}
+ >
+ Retry same request
+
+ )}
+
+ {status && (
+
+ {status}
+
+ )}
+
+ );
+}
diff --git a/desktop/src/features/agents/ui/KnownDesktops.tsx b/desktop/src/features/agents/ui/KnownDesktops.tsx
index 45e7aa68eaa..5f4328b332c 100644
--- a/desktop/src/features/agents/ui/KnownDesktops.tsx
+++ b/desktop/src/features/agents/ui/KnownDesktops.tsx
@@ -1,4 +1,8 @@
-import { DesktopStopControl, DesktopStopReceiver } from "./DesktopStopControl";
+import {
+ DesktopLifecycleControl,
+ DesktopLifecycleReceiver,
+} from "./DesktopLifecycleControl";
+import { DesktopStopControl } from "./DesktopStopControl";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useIdentityQuery } from "@/shared/api/hooks";
@@ -62,6 +66,7 @@ function useDesktopList() {
/** Startup and Agents share the existing owner/community query cache. */
export function DesktopListStartup() {
+ const [epoch, setEpoch] = useState(0);
const { refetch } = useDesktopList();
const { refetch: pulse } = useDesktopObservations(useDesktopScope());
const { refetch: report } = useDesktopCapabilities(useDesktopScope());
@@ -71,6 +76,7 @@ export function DesktopListStartup() {
void report();
}, DESKTOP_PULSE_MS);
const unsubscribe = relayClient.subscribeToReconnects(() => {
+ setEpoch((n) => n + 1);
void refetch();
void pulse();
void report();
@@ -80,7 +86,8 @@ export function DesktopListStartup() {
unsubscribe();
};
}, [refetch, pulse, report]);
- return ;
+ const scope = useDesktopScope();
+ return ;
}
export function KnownDesktops() {
@@ -170,6 +177,13 @@ export function DesktopListView({
Partial list: showing up to 100 profiles.
)}
{list && !list.rows.length && !error && No Desktop profiles found.
}
+ {scope && list && (
+
+ )}
{list?.rows.map((row) => (
From c6f3e3eb2fcd0369e9bcb5adf9f245591697e13e Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 17:54:00 -0400
Subject: [PATCH 22/51] fix(multiverse): retain superseded Stop outcomes for
exact retry
Signed-off-by: Logan Johnson
---
desktop/src-tauri/src/commands/desktop_stop.rs | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/desktop/src-tauri/src/commands/desktop_stop.rs b/desktop/src-tauri/src/commands/desktop_stop.rs
index 2c9623fb29a..d2361b92cf9 100644
--- a/desktop/src-tauri/src/commands/desktop_stop.rs
+++ b/desktop/src-tauri/src/commands/desktop_stop.rs
@@ -86,13 +86,14 @@ pub async fn receive_desktop_stop(
if managed_agents::placement::desired(&conn, &target.agent)?
.is_some_and(|(host, _)| host == desktop)
{
- return StopResult {
+ let result = StopResult {
target,
request: event.id.to_hex(),
outcome: StopOutcome::Unknown,
}
- .sign(&scope.owner_keys)
- .map(Some);
+ .sign(&scope.owner_keys)?;
+ remote_stop::save_result(&mut conn, &event.id.to_hex(), &result.as_json())?;
+ return Ok(Some(result));
}
let owned = owned_local(&app, &state, &owner, &target.agent)?;
remote_stop::receive(
From 7d00439d05e636f2a1fa61049ede7e6a5db71f66 Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 18:36:53 -0400
Subject: [PATCH 23/51] test(multiverse): scope Stop row and exercise mounted
launch refusal
Signed-off-by: Logan Johnson
---
desktop/tests/e2e/desktop-stop.spec.ts | 69 +++++++++++++++++++++++++-
1 file changed, 67 insertions(+), 2 deletions(-)
diff --git a/desktop/tests/e2e/desktop-stop.spec.ts b/desktop/tests/e2e/desktop-stop.spec.ts
index a656aa45623..21df14154dc 100644
--- a/desktop/tests/e2e/desktop-stop.spec.ts
+++ b/desktop/tests/e2e/desktop-stop.spec.ts
@@ -31,12 +31,20 @@ test("remote Stop distinguishes delivery, uncertainty, and confirmed result", as
confirmed: boolean;
prepared: number;
sends: string[];
+ lifecyclePrepared: number;
+ lifecycleSends: string[];
};
__TAURI_INTERNALS__: {
invoke: (command: string, payload?: any, options?: any) => Promise;
};
};
- w.__STOP_FIXTURE__ = { confirmed: false, prepared: 0, sends: [] };
+ w.__STOP_FIXTURE__ = {
+ confirmed: false,
+ prepared: 0,
+ sends: [],
+ lifecyclePrepared: 0,
+ lifecycleSends: [],
+ };
const original = w.__TAURI_INTERNALS__.invoke.bind(w.__TAURI_INTERNALS__);
const now = Math.floor(Date.now() / 1000);
const local = "11111111-1111-4111-8111-111111111111";
@@ -74,6 +82,19 @@ test("remote Stop distinguishes delivery, uncertainty, and confirmed result", as
reported: now,
runtimes: [],
}));
+ case "observe_desktop_placement":
+ return;
+ case "read_desktop_placement":
+ case "receive_desktop_lifecycle":
+ return null;
+ case "prepare_desktop_lifecycle":
+ w.__STOP_FIXTURE__.lifecyclePrepared++;
+ return sign(50182, [
+ ["p", payload.owner],
+ ["d", payload.desktop],
+ ]);
+ case "read_desktop_lifecycle_results":
+ return "provisioning_unavailable";
case "prepare_desktop_stop":
w.__STOP_FIXTURE__.prepared++;
return sign(50180, [
@@ -88,6 +109,8 @@ test("remote Stop distinguishes delivery, uncertainty, and confirmed result", as
const wire = JSON.parse(payload.message.data);
if (wire[0] === "EVENT" && wire[1]?.kind === 50180)
w.__STOP_FIXTURE__.sends.push(JSON.stringify(wire[1]));
+ if (wire[0] === "EVENT" && wire[1]?.kind === 50182)
+ w.__STOP_FIXTURE__.lifecycleSends.push(JSON.stringify(wire[1]));
break;
}
}
@@ -98,7 +121,7 @@ test("remote Stop distinguishes delivery, uncertainty, and confirmed result", as
const desktops = page.getByRole("region", { name: "Known Desktops" });
await desktops.getByRole("button", { name: "Refresh", exact: true }).click();
await expect(
- desktops.getByText("Lab Desktop", { exact: true }),
+ desktops.getByRole("listitem").getByText("Lab Desktop", { exact: true }),
).toBeVisible();
await desktops
.getByRole("combobox", { name: "Agent to stop on Lab Desktop" })
@@ -160,4 +183,46 @@ test("remote Stop distinguishes delivery, uncertainty, and confirmed result", as
await desktops.screenshot({
path: "test-results/desktop-stop/04-confirmed.png",
});
+
+ // The mounted lifecycle selector shares host labels with the Stop rows.
+ // IPC explicitly refuses launch; no native process is created by this fixture.
+ const controls = desktops.getByRole("region", {
+ name: "Agent placement controls",
+ });
+ await controls
+ .getByRole("combobox", { name: "Agent to place" })
+ .selectOption(agent);
+ await controls
+ .getByRole("combobox", { name: "Destination Desktop" })
+ .selectOption("22222222-2222-4222-8222-222222222222");
+ await controls
+ .getByRole("button", { name: "Start on destination", exact: true })
+ .click();
+ await expect(controls.getByRole("status")).toHaveText(
+ "Destination keyless launch provisioning is unavailable. No new process was started.",
+ );
+ await waitForAnimations(page);
+ await desktops.screenshot({
+ path: "test-results/desktop-stop/05-launch-unavailable.png",
+ });
+ await controls
+ .getByRole("button", { name: "Retry same request", exact: true })
+ .click();
+ await expect(controls.getByRole("status")).toHaveText(
+ "Destination keyless launch provisioning is unavailable. No new process was started.",
+ );
+ const lifecycle = await page.evaluate(
+ () =>
+ (
+ window as typeof window & {
+ __STOP_FIXTURE__: {
+ lifecyclePrepared: number;
+ lifecycleSends: string[];
+ };
+ }
+ ).__STOP_FIXTURE__,
+ );
+ expect(lifecycle.lifecyclePrepared).toBe(1);
+ expect(lifecycle.lifecycleSends).toHaveLength(2);
+ expect(lifecycle.lifecycleSends[1]).toBe(lifecycle.lifecycleSends[0]);
});
From 943b62af7abde01b8e6fbe0c6d77ba377fbe2513 Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 19:18:49 -0400
Subject: [PATCH 24/51] fix(desktop): keep lifecycle receiver failures out of
shell layout
Signed-off-by: Logan Johnson
---
.../src/features/agents/desktopList.test.mjs | 3 +
.../ui/DesktopLifecycleControl.test.mjs | 108 +++++++++++++++++-
.../agents/ui/DesktopLifecycleControl.tsx | 26 +++--
desktop/src/testing/e2eBridge.ts | 6 +
.../e2e/top-chrome-zoom-clearance.spec.ts | 17 ++-
desktop/tests/helpers/bridge.ts | 2 +
6 files changed, 151 insertions(+), 11 deletions(-)
diff --git a/desktop/src/features/agents/desktopList.test.mjs b/desktop/src/features/agents/desktopList.test.mjs
index 5e21adbb316..828964f432b 100644
--- a/desktop/src/features/agents/desktopList.test.mjs
+++ b/desktop/src/features/agents/desktopList.test.mjs
@@ -160,6 +160,8 @@ test("rendered list distinguishes current, partial, unavailable and empty withou
});
test("mounted cache clears both scopes, fences late reads and retains rows on failure", async (t) => {
+ const originalRaf = globalThis.requestAnimationFrame;
+ globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0);
const { JSDOM } = await import("jsdom");
const dom = new JSDOM("
", {
url: "https://desktop.test",
@@ -375,6 +377,7 @@ test("mounted cache clears both scopes, fences late reads and retains rows on fa
"reconnect producer unsubscribed on unmount",
);
t.mock.timers.reset();
+ globalThis.requestAnimationFrame = originalRaf;
dom.window.close();
}
});
diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs
index d6919521225..f41068c6136 100644
--- a/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs
+++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs
@@ -3,7 +3,11 @@ import test from "node:test";
import React from "react";
import { JSDOM } from "jsdom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { DesktopLifecycleControl } from "./DesktopLifecycleControl.tsx";
+import {
+ DesktopLifecycleControl,
+ DesktopLifecycleReceiver,
+} from "./DesktopLifecycleControl.tsx";
+import { toast } from "sonner";
import { relayClient } from "../../../shared/api/relayClient.ts";
test("mounted Start exposes unavailable provisioning and exact retry; Restart resolves source", async () => {
@@ -131,3 +135,105 @@ test("mounted Start exposes unavailable provisioning and exact retry; Restart re
dom.window.close();
}
});
+
+test("receiver failure is a scope-owned notification, not pre-shell layout", async () => {
+ const originalRaf = globalThis.requestAnimationFrame;
+ globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0);
+ const dom = new JSDOM("
", {
+ url: "https://desktop.test",
+ });
+ Object.assign(globalThis, {
+ window: dom.window,
+ document: dom.window.document,
+ localStorage: dom.window.localStorage,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ });
+ const { createRoot } = await import("react-dom/client");
+ const originals = {
+ fetch: relayClient.fetchEvents,
+ subscribe: relayClient.subscribeLive,
+ };
+ let readiness;
+ let closed = 0;
+ let rejectLate;
+ let delayed = false;
+ relayClient.fetchEvents = async () => {
+ if (delayed)
+ return new Promise((_, reject) => {
+ rejectLate = reject;
+ });
+ return [];
+ };
+ relayClient.subscribeLive = async (_filter, _event, onReadiness) => {
+ readiness = onReadiness;
+ return () => {
+ closed++;
+ };
+ };
+ window.__TAURI_INTERNALS__ = {
+ invoke: async () => {
+ throw new Error("fixture: storage unavailable");
+ },
+ };
+ const root = createRoot(document.getElementById("root"));
+ const scope = { owner: "owner", community: "wss://one.example" };
+ const warnings = () =>
+ toast
+ .getToasts()
+ .filter((t) => String(t.title).startsWith("Desktop lifecycle"));
+ try {
+ await React.act(async () =>
+ root.render(React.createElement(DesktopLifecycleReceiver, { scope })),
+ );
+ assert.equal(
+ document.getElementById("root").childElementCount,
+ 0,
+ "startup must not render in-flow failure UI",
+ );
+ assert.equal(warnings().length, 1);
+ assert.equal(
+ warnings()[0].title,
+ "Desktop lifecycle receiver is unavailable.",
+ );
+ assert.equal(warnings()[0].duration, Infinity);
+ assert.equal(warnings()[0].closeButton, true);
+ readiness("closed");
+ assert.equal(
+ warnings().length,
+ 1,
+ "repeated failures update one notification",
+ );
+ await React.act(async () =>
+ root.render(
+ React.createElement(DesktopLifecycleReceiver, { scope: null }),
+ ),
+ );
+ assert.equal(warnings().length, 0, "leaving the scope removes its warning");
+ readiness("closed");
+ assert.equal(
+ warnings().length,
+ 0,
+ "retired receiver cannot notify another scope",
+ );
+ delayed = true;
+ await React.act(async () =>
+ root.render(React.createElement(DesktopLifecycleReceiver, { scope })),
+ );
+ assert.equal(typeof rejectLate, "function");
+ await React.act(async () => root.unmount());
+ await React.act(async () => rejectLate(new Error("late failure")));
+ assert.equal(
+ warnings().length,
+ 0,
+ "late startup rejection must not recreate the warning",
+ );
+ assert.equal(closed, 2, "both failed subscriptions are released");
+ } finally {
+ await React.act(async () => root.unmount());
+ relayClient.fetchEvents = originals.fetch;
+ relayClient.subscribeLive = originals.subscribe;
+ for (const warning of warnings()) toast.dismiss(warning.id);
+ globalThis.requestAnimationFrame = originalRaf;
+ dom.window.close();
+ }
+});
diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx
index 5baffa6f4cb..d05efee2511 100644
--- a/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx
+++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react";
+import { toast } from "sonner";
import { Button } from "@/shared/ui/button";
import type { RelayEvent } from "@/shared/api/types";
import type { DesktopRow, DesktopScope } from "../desktopList";
@@ -14,31 +15,38 @@ export function DesktopLifecycleReceiver({
}: {
scope: DesktopScope | null;
}) {
- const [error, setError] = useState("");
const { owner, community } = scope ?? {};
useEffect(() => {
if (!owner || !community) return;
let active = true;
let close: (() => void) | undefined;
- setError("");
- void receiveLifecycle({ owner, community }, () => active, setError)
+ let notification: string | number | undefined;
+ const reportError = (message: string) => {
+ if (!active) return;
+ // Startup mounts before the app shell: failure UI must not participate
+ // in layout or displace the fixed macOS window controls. Keep one visible
+ // notification for this receiver, and retire it with its owner/scope.
+ notification = toast.error(message, {
+ id: notification,
+ duration: Infinity,
+ closeButton: true,
+ });
+ };
+ void receiveLifecycle({ owner, community }, () => active, reportError)
.then((fn) => {
if (active) close = fn;
else fn();
})
.catch(() => {
- if (active) setError("Desktop lifecycle receiver is unavailable.");
+ reportError("Desktop lifecycle receiver is unavailable.");
});
return () => {
active = false;
close?.();
+ if (notification !== undefined) toast.dismiss(notification);
};
}, [owner, community]);
- return error ? (
-
- {error}
-
- ) : null;
+ return null;
}
function message(outcome: LifecycleOutcome) {
switch (outcome) {
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 89b1fb20aed..437916a1315 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -306,6 +306,7 @@ type E2eConfig = {
mcp?: MockCommandAvailability;
};
managedAgents?: MockManagedAgentSeed[];
+ desktopLifecycleObservationError?: string;
/** Result returned by the mocked `add_agent_to_huddle` command. */
addAgentToHuddleResult?: {
ephemeral_added: boolean;
@@ -13917,6 +13918,11 @@ export function maybeInstallE2eTauriMocks() {
],
};
}
+ case "observe_desktop_placement": {
+ const error = activeConfig?.mock?.desktopLifecycleObservationError;
+ if (error) throw new Error(error);
+ return null;
+ }
case "list_managed_agents":
return handleListManagedAgents(activeConfig);
case "get_agent_memory":
diff --git a/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts b/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts
index d1615dbcc64..1d4d6795a76 100644
--- a/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts
+++ b/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts
@@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test";
import { readFileSync } from "node:fs";
import { installMockBridge } from "../helpers/bridge";
+import { waitForAnimations } from "../helpers/animations";
type TauriConfig = {
app: {
@@ -109,8 +110,18 @@ test.describe("top chrome macOS traffic-light clearance under text zoom", () =>
page,
}) => {
await spoofMacPlatform(page);
- await installMockBridge(page);
+ await installMockBridge(page, {
+ desktopLifecycleObservationError:
+ "fixture: lifecycle storage unavailable",
+ });
await page.goto("/");
+ // A failed global receiver must remain visible without entering the shell's
+ // layout flow. This also forces the error to settle before measuring chrome.
+ await expect(
+ page.getByText("Desktop lifecycle receiver is unavailable.", {
+ exact: true,
+ }),
+ ).toBeVisible();
// Lock the native and webview placements together: removing this explicit
// Tauri inset or shifting the nav row regresses the macOS chrome alignment.
@@ -133,6 +144,10 @@ test.describe("top chrome macOS traffic-light clearance under text zoom", () =>
);
await expectNavButtonsFixedSize(page);
await expectTopChromeFixedHeight(page);
+ await waitForAnimations(page);
+ await page.screenshot({
+ path: "test-results/desktop-lifecycle/receiver-error-chrome.png",
+ });
});
test("nav buttons still clear the traffic lights when zoomed out", async ({
diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts
index c3f4ed69f4c..7c029f9b647 100644
--- a/desktop/tests/helpers/bridge.ts
+++ b/desktop/tests/helpers/bridge.ts
@@ -221,6 +221,8 @@ type MockBridgeOptions = {
mcp?: MockCommandAvailability;
};
managedAgents?: MockManagedAgentSeed[];
+ /** Fail lifecycle history admission to exercise the global receiver warning. */
+ desktopLifecycleObservationError?: string;
/** Result returned by the mocked `add_agent_to_huddle` command. */
addAgentToHuddleResult?: {
ephemeral_added: boolean;
From cbb4091ef349927aee47d5387c574b0bdaf0714f Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 22:37:49 -0400
Subject: [PATCH 25/51] fix(desktop): preserve launch community separately from
runtime aliases
Signed-off-by: Logan Johnson
---
desktop/src-tauri/src/commands/agents.rs | 17 ++--
.../src/managed_agents/remote_stop.rs | 93 ++++++++++++++++---
.../src-tauri/src/managed_agents/restore.rs | 16 ++--
.../src-tauri/src/managed_agents/runtime.rs | 9 +-
.../src/managed_agents/runtime_commands.rs | 9 +-
desktop/src-tauri/src/relay/scope.rs | 30 ++++++
6 files changed, 139 insertions(+), 35 deletions(-)
diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs
index a3bc56882e2..df092d7dc44 100644
--- a/desktop/src-tauri/src/commands/agents.rs
+++ b/desktop/src-tauri/src/commands/agents.rs
@@ -176,14 +176,19 @@ async fn start_local_agent_with_preflight(
replay_floor_unix: Option,
) -> Result {
let launch_owner = workspace_owner_hex(state)?;
- let launch_key = crate::managed_agents::ManagedAgentRuntimeKey::new(
- pubkey,
- &relay_ws_url_with_override(state),
+ // Runtime keys fold loopback aliases for process bookkeeping, not tenant
+ // identity. Preserve the workspace authority across the preflight await.
+ let launch_relay = crate::relay::bind_expected_relay_scope(
+ expected_relay_url,
+ relay_ws_url_with_override(state),
)?;
+ let launch_key =
+ crate::managed_agents::ManagedAgentRuntimeKey::new(pubkey, launch_relay.as_str())?;
let resume = if matches!(intent, LocalStartIntent::Explicit) {
Some(crate::managed_agents::remote_stop::capture_resume(
app,
&launch_key,
+ launch_relay.as_str(),
&launch_owner,
)?)
} else {
@@ -236,10 +241,8 @@ async fn start_local_agent_with_preflight(
// below — the check is tied to its use, so a switch landing after this
// point can no longer retarget the spawn (it only changes state this
// call no longer consults).
- let workspace_relay_url = crate::relay::bind_expected_relay_scope(
- expected_relay_url.or(Some(launch_key.relay_url.as_str())),
- crate::relay::relay_ws_url_with_override(state),
- )?;
+ let workspace_relay_url =
+ launch_relay.revalidate(crate::relay::relay_ws_url_with_override(state))?;
// Bind the active owner after the same final await as the relay. A
// same-relay identity replacement during mesh preflight must not release
// the stale preflight owner to spawn.
diff --git a/desktop/src-tauri/src/managed_agents/remote_stop.rs b/desktop/src-tauri/src/managed_agents/remote_stop.rs
index 55d03649004..e6c93750389 100644
--- a/desktop/src-tauri/src/managed_agents/remote_stop.rs
+++ b/desktop/src-tauri/src/managed_agents/remote_stop.rs
@@ -122,11 +122,12 @@ pub(crate) struct ResumeTicket {
}
pub(crate) fn capture_resume(
- app: &AppHandle,
+ app: &AppHandle,
key: &ManagedAgentRuntimeKey,
+ community: &str,
owner: &str,
) -> Result {
- let conn = connection(app, key, owner)?;
+ let conn = connection(app, community, owner)?;
schema(&conn)?;
let previous = conn
.query_row(
@@ -140,12 +141,11 @@ pub(crate) fn capture_resume(
}
fn connection(
- app: &AppHandle,
- key: &ManagedAgentRuntimeKey,
+ app: &AppHandle,
+ community: &str,
owner: &str,
) -> Result {
- let path =
- scoped_retention_db_path(&super::managed_agents_base_dir(app)?, &key.relay_url, owner);
+ let path = scoped_retention_db_path(&super::managed_agents_base_dir(app)?, community, owner);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
@@ -155,8 +155,9 @@ fn connection(
/// Every ordinary spawn passes here, including restore/config/reconcile.
/// Caller holds the existing transition lock through child registration.
pub(crate) fn check_launch(
- app: &AppHandle,
+ app: &AppHandle,
key: &ManagedAgentRuntimeKey,
+ community: &str,
owner: Option<&str>,
resume: Option<&ResumeTicket>,
) -> Result<(), String> {
@@ -165,18 +166,18 @@ pub(crate) fn check_launch(
if owner != Some(current_owner.as_str()) {
return Err("Desktop launch owner changed".into());
}
- let conn = connection(app, key, ¤t_owner)?;
+ let conn = connection(app, community, ¤t_owner)?;
schema(&conn)?;
let scope = super::retention::RetentionScope {
db_path: scoped_retention_db_path(
&super::managed_agents_base_dir(app)?,
- &key.relay_url,
+ community,
¤t_owner,
),
- relay_url: key.relay_url.clone(),
+ relay_url: community.to_owned(),
owner_keys: state.signing_keys()?,
};
- let mut local_conn = connection(app, key, ¤t_owner)?;
+ let mut local_conn = connection(app, community, ¤t_owner)?;
let host = crate::commands::desktop_stop::local_id(&mut local_conn, &scope)?;
if super::placement::blocked(&conn, &key.pubkey, &host)?
&& (resume.is_none() || super::placement::has_start(&conn, &key.pubkey)?)
@@ -210,8 +211,9 @@ fn allow_launch(row: Option<&(String, bool)>, resume: Option<&ResumeTicket>) ->
/// A failed spawn must not unblock config/restore. Commit only after the child
/// has its ordinary receipt and tracked handle, still under the transition lock.
pub(crate) fn finish_resume(
- app: &AppHandle,
+ app: &AppHandle,
key: &ManagedAgentRuntimeKey,
+ community: &str,
owner: Option<&str>,
ticket: Option<&ResumeTicket>,
) -> Result<(), String> {
@@ -219,8 +221,8 @@ pub(crate) fn finish_resume(
return Ok(());
}
let owner = owner.ok_or("Desktop launch owner unavailable")?;
- check_launch(app, key, Some(owner), ticket)?;
- connection(app, key, owner)?
+ check_launch(app, key, community, Some(owner), ticket)?;
+ connection(app, community, owner)?
.execute(
"UPDATE desktop_stop_fence SET blocked=0 WHERE agent=?1",
[&key.pubkey],
@@ -250,6 +252,69 @@ mod tests {
.sign_with_keys(keys)
.unwrap()
}
+ #[test]
+ fn launch_reads_receiver_fence_in_original_community_not_runtime_alias() {
+ let mut context = tauri::test::mock_context(tauri::test::noop_assets());
+ context.config_mut().identifier = format!("buzz-test-{}", uuid::Uuid::new_v4());
+ let state = crate::app_state::build_app_state();
+ let keys = state.signing_keys().unwrap();
+ let owner = keys.public_key().to_hex();
+ let app = tauri::test::mock_builder()
+ .manage(state)
+ .build(context)
+ .unwrap();
+ let root = app.path().app_data_dir().unwrap();
+ let community = "ws://localhost:3037";
+ let agent = Keys::generate().public_key().to_hex();
+ let key = ManagedAgentRuntimeKey::new(&agent, community).unwrap();
+ assert_ne!(key.relay_url, community);
+ let scope = super::super::retention::RetentionScope {
+ db_path: scoped_retention_db_path(&root.join("agents"), community, &owner),
+ relay_url: community.into(),
+ owner_keys: keys.clone(),
+ };
+ let mut conn = connection(app.handle(), community, &owner).unwrap();
+ let host = crate::commands::desktop_stop::local_id(&mut conn, &scope).unwrap();
+ let target = StopTarget {
+ v: 1,
+ community: community.into(),
+ desktop: host,
+ agent,
+ };
+ let stop = request(&keys, &target, 100);
+ receive(
+ &mut conn,
+ &stop,
+ &keys,
+ community,
+ &target.desktop,
+ true,
+ |_| Ok(()),
+ )
+ .unwrap();
+ assert!(check_launch(app.handle(), &key, community, Some(&owner), None).is_err());
+ // The numeric community is a different authority, even though the
+ // process bookkeeping key historically folds the two spellings.
+ assert!(check_launch(app.handle(), &key, &key.relay_url, Some(&owner), None).is_ok());
+ let resume = capture_resume(app.handle(), &key, community, &owner).unwrap();
+ assert!(check_launch(app.handle(), &key, community, Some(&owner), Some(&resume)).is_ok());
+ let newer = request(&keys, &target, 101);
+ receive(
+ &mut conn,
+ &newer,
+ &keys,
+ community,
+ &target.desktop,
+ true,
+ |_| Ok(()),
+ )
+ .unwrap();
+ assert!(check_launch(app.handle(), &key, community, Some(&owner), Some(&resume)).is_err());
+ drop(conn);
+ drop(app);
+ std::fs::remove_dir_all(root).unwrap();
+ }
+
#[test]
fn launch_fence_requires_explicit_start_and_rejects_delayed_preflight() {
let stopped = ("stop-a".to_owned(), true);
diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs
index 66aa95cba74..5e282c239d4 100644
--- a/desktop/src-tauri/src/managed_agents/restore.rs
+++ b/desktop/src-tauri/src/managed_agents/restore.rs
@@ -21,7 +21,11 @@ use tauri::Manager;
enum SpawnOutcome {
/// Boxed: the spawned process carries its full spawn-config snapshot, so an
/// inline variant would make every `Skipped`/`Failed` outcome pay for it.
- Spawned(super::ManagedAgentRuntimeKey, Box),
+ Spawned(
+ super::ManagedAgentRuntimeKey,
+ String,
+ Box,
+ ),
Skipped,
Failed(String),
}
@@ -341,7 +345,7 @@ pub async fn restore_managed_agents_on_launch(
spawn_agent_child(
app,
record,
- &key.relay_url,
+ &relay_url,
true,
owner_hex_ref,
None,
@@ -349,7 +353,7 @@ pub async fn restore_managed_agents_on_launch(
)
}) {
Ok(process) => {
- SpawnOutcome::Spawned(key, Box::new(process))
+ SpawnOutcome::Spawned(key, relay_url, Box::new(process))
}
Err(error) => SpawnOutcome::Failed(error),
}
@@ -388,7 +392,7 @@ pub async fn restore_managed_agents_on_launch(
// Skipped means a concurrent reconcile already owns a live child for
// this pair; leave its runtime and record state untouched.
SpawnOutcome::Skipped => continue,
- SpawnOutcome::Spawned(key, mut process) => {
+ SpawnOutcome::Spawned(key, relay_url, mut process) => {
let Ok(record) = find_managed_agent_mut(&mut records, &pubkey) else {
continue;
};
@@ -416,11 +420,11 @@ pub async fn restore_managed_agents_on_launch(
key.clone(),
super::ManagedAgentPairRuntime::starting(*process),
);
- // Carry the spawn key's relay into profile reconciliation so
+ // Carry the original launch community into profile reconciliation so
// the background task queries/publishes on the relay this
// spawn was actually keyed to — not whatever workspace is
// active when the task eventually executes.
- successfully_spawned.push((pubkey, key.relay_url.clone()));
+ successfully_spawned.push((pubkey, relay_url));
}
SpawnOutcome::Failed(error) => {
let Ok(record) = find_managed_agent_mut(&mut records, &pubkey) else {
diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs
index 8d97fd1fff1..8efe5826291 100644
--- a/desktop/src-tauri/src/managed_agents/runtime.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime.rs
@@ -477,7 +477,7 @@ pub(crate) fn spawn_agent_child_with_broker(
broker: Option<&super::broker_launch::BrokerSession>,
) -> Result {
let key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), relay_url)?;
- super::remote_stop::check_launch(app, &key, owner_hex, resume)?;
+ super::remote_stop::check_launch(app, &key, relay_url, owner_hex, resume)?;
if let Some(session) = broker {
session.validate(super::broker_launch::LaunchScope {
owner: owner_hex.ok_or("Desktop owner unavailable")?,
@@ -574,7 +574,8 @@ pub(crate) fn spawn_agent_child_with_broker(
// The caller supplies the explicit canonical pair relay. This is the only
// relay this child may connect to, regardless of the record/workspace default.
- let effective_relay_url = runtime_key.relay_url.clone();
+ // Process identity normalization must not select a different relay tenant.
+ let effective_relay_url = relay_url.to_owned();
// Augment PATH for DMG launches so child processes can find:
// - bundled CLI via ~/.local/bin symlink
// - nvm-managed node/npm (nvm initializes only in interactive shells)
@@ -946,7 +947,7 @@ pub fn start_managed_agent_process(
let mut process = spawn_agent_child(
app,
record,
- &key.relay_url,
+ workspace_relay.as_str(),
false,
owner_hex,
replay_floor_unix,
@@ -973,7 +974,7 @@ pub fn start_managed_agent_process(
record.last_error_code = None;
runtimes.insert(key.clone(), ManagedAgentPairRuntime::starting(process));
- super::remote_stop::finish_resume(app, &key, owner_hex, resume)?;
+ super::remote_stop::finish_resume(app, &key, workspace_relay.as_str(), owner_hex, resume)?;
Ok(())
}
diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs
index 665e3c5d426..ca7e7cc21a3 100644
--- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs
@@ -322,6 +322,7 @@ pub(crate) fn start_pair_locked(
Some(super::remote_stop::capture_resume(
&app,
&key,
+ &relay_url,
owner.as_deref().ok_or("Desktop owner unavailable")?,
)?)
} else {
@@ -330,7 +331,7 @@ pub(crate) fn start_pair_locked(
let mut process = super::spawn_agent_child_with_broker(
&app,
record,
- &key.relay_url,
+ &relay_url,
lazy,
owner.as_deref(),
None,
@@ -355,7 +356,7 @@ pub(crate) fn start_pair_locked(
record.last_stopped_at = None;
record.last_error = None;
runtimes.insert(key.clone(), ManagedAgentPairRuntime::starting(process));
- super::remote_stop::finish_resume(&app, &key, owner.as_deref(), resume.as_ref())?;
+ super::remote_stop::finish_resume(&app, &key, &relay_url, owner.as_deref(), resume.as_ref())?;
let status = status_for(&app, record, &key, runtimes.get(&key), None);
drop(runtimes);
save_managed_agents(&app, &records)?;
@@ -462,7 +463,7 @@ async fn probe_agent_relay_access(
let key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), &requested_relay_url)?;
let keys = nostr::Keys::parse(record.private_key_nsec.trim())
.map_err(|error| format!("invalid managed-agent key: {error}"))?;
- let api_base = crate::relay::relay_http_base_url(&key.relay_url);
+ let api_base = crate::relay::relay_http_base_url(&requested_relay_url);
tokio::time::timeout(
std::time::Duration::from_secs(10),
crate::relay::query_relay_at_with_keys(
@@ -563,7 +564,7 @@ pub async fn reconcile_managed_agent_runtimes(
Ok((record, key, requested)) => {
match start_pair(
record.pubkey.clone(),
- key.relay_url.clone(),
+ requested.clone(),
true,
Some(&record.updated_at),
false,
diff --git a/desktop/src-tauri/src/relay/scope.rs b/desktop/src-tauri/src/relay/scope.rs
index b9c73328aff..baaee4df8ab 100644
--- a/desktop/src-tauri/src/relay/scope.rs
+++ b/desktop/src-tauri/src/relay/scope.rs
@@ -41,6 +41,12 @@ impl ScopedWorkspaceRelay {
pub fn as_str(&self) -> &str {
&self.0
}
+
+ /// Recheck a captured workspace after preflight without substituting a
+ /// runtime-normalized URL (which may alias distinct tenant authorities).
+ pub fn revalidate(self, workspace_relay_url: String) -> Result {
+ bind_expected_relay_scope(Some(self.as_str()), workspace_relay_url)
+ }
}
/// Validate a caller-captured relay scope against one workspace-relay read
@@ -119,6 +125,30 @@ mod tests {
bind_expected_signer,
};
+ #[test]
+ fn preflight_revalidation_preserves_localhost_authority_not_runtime_alias() {
+ let relay = "ws://localhost:3037";
+ let captured = bind_expected_relay_scope(None, relay.into()).unwrap();
+ let runtime =
+ crate::managed_agents::ManagedAgentRuntimeKey::new("a".repeat(64), captured.as_str())
+ .unwrap();
+ assert_eq!(runtime.relay_url, "ws://127.0.0.1:3037");
+ assert_eq!(captured.revalidate(relay.into()).unwrap().as_str(), relay);
+ }
+
+ #[test]
+ fn preflight_revalidation_rejects_switch_even_to_same_runtime_alias() {
+ for changed in ["ws://127.0.0.1:3037", "wss://other.example"] {
+ let captured = bind_expected_relay_scope(None, "ws://localhost:3037".into()).unwrap();
+ assert!(captured.revalidate(changed.into()).is_err());
+ }
+ assert!(bind_expected_relay_scope(
+ Some("ws://localhost:3037"),
+ "ws://127.0.0.1:3037".into(),
+ )
+ .is_err());
+ }
+
#[test]
fn matching_scope_passes_across_ws_http_normalization() {
assert_expected_relay_scope(Some("wss://tenant-a.example"), "https://tenant-a.example")
From a7d32b96c7ccaa82ab594bcc0123e9829321714f Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 22:44:35 -0400
Subject: [PATCH 26/51] fix(desktop): diagnose and explicitly recover lifecycle
receivers
Signed-off-by: Logan Johnson
---
desktop/src/features/agents/AGENTS.md | 7 +-
.../features/agents/desktopLifecycle.test.mjs | 136 +++++++++++++
.../src/features/agents/desktopLifecycle.ts | 191 +++++++++++-------
.../agents/desktopLifecycleDiagnostics.ts | 41 ++++
.../ui/DesktopLifecycleControl.test.mjs | 23 ++-
.../agents/ui/DesktopLifecycleControl.tsx | 28 ++-
.../e2e/top-chrome-zoom-clearance.spec.ts | 4 +-
7 files changed, 348 insertions(+), 82 deletions(-)
create mode 100644 desktop/src/features/agents/desktopLifecycleDiagnostics.ts
diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md
index be408c39b36..2589afefb4d 100644
--- a/desktop/src/features/agents/AGENTS.md
+++ b/desktop/src/features/agents/AGENTS.md
@@ -312,7 +312,12 @@ with a TypeScript lookup table or an id comparison in a component.
Known Desktops exposes an owner-private, explicitly selected agent+Desktop Stop,
not inferred agent location. The app-scoped receiver subscribes live only;
-reopening never replays commands. An explicit retry republishes the exact request;
+reopening never replays commands. Receiver initialization reports a safe failure
+stage without exposing raw transport/IPC exceptions. Its scope-owned notification
+can explicitly retry the **receiver** with a fresh live-only subscription; that
+must discard queued callbacks from the retired receiver, not retry an operation.
+A readiness timeout is unconfirmed delivery, not a failed initialization; late
+EOSE clears that warning after successful projection. An explicit operation retry republishes the exact request;
the relay redelivers stored Stop duplicates without repeating relay side effects.
The receiver returns saved results or Unknown, never repeats a consumed Stop.
Native owner-delegation and community checks
diff --git a/desktop/src/features/agents/desktopLifecycle.test.mjs b/desktop/src/features/agents/desktopLifecycle.test.mjs
index b15daccfcff..33de28d85ba 100644
--- a/desktop/src/features/agents/desktopLifecycle.test.mjs
+++ b/desktop/src/features/agents/desktopLifecycle.test.mjs
@@ -238,3 +238,139 @@ test("receiver projects history without executing it and invalidates live work o
assert.equal(f.errors.length, 1);
close();
});
+
+for (const stage of [
+ "subscription",
+ "history",
+ "projection",
+ "reconciliation",
+]) {
+ test(`receiver reports safe ${stage} failure and never admits queued work`, async () => {
+ const f = fixture();
+ const secret = "private key /home/private bearer secret";
+ if (stage === "subscription")
+ f.relay.subscribeLive = async () => {
+ throw Error(secret);
+ };
+ if (stage === "history")
+ f.relay.fetchEvents = async () => {
+ throw Error(secret);
+ };
+ const ipc = async (command, args) => {
+ if (
+ command === "observe_desktop_placement" &&
+ ((stage === "projection" && !args.reconcile) ||
+ (stage === "reconciliation" && args.reconcile))
+ )
+ throw Error(secret);
+ return f.ipc(command, args);
+ };
+ const started = receiveLifecycle(
+ scope,
+ () => true,
+ (e) => f.errors.push(e),
+ ipc,
+ f.relay,
+ );
+ f.deliver({ id: "queued", kind: 50182 });
+ await assert.rejects(started, (error) => {
+ assert.match(error.message, new RegExp(`${stage}: request failed`));
+ assert.doesNotMatch(error.message, /private|bearer|secret/);
+ return true;
+ });
+ await tick();
+ assert.equal(
+ f.calls.filter(([c]) => c === "receive_desktop_lifecycle").length,
+ 0,
+ );
+ assert.deepEqual(
+ f.errors,
+ [],
+ "discarded callbacks cannot replace the startup diagnosis",
+ );
+ });
+}
+
+test("explicit receiver recovery discards old queued work and projects history without replay", async () => {
+ const f = fixture();
+ let rejectHistory;
+ f.relay.fetchEvents = () =>
+ new Promise((_, reject) => {
+ rejectHistory = reject;
+ });
+ const failed = receiveLifecycle(
+ scope,
+ () => true,
+ () => {},
+ f.ipc,
+ f.relay,
+ );
+ await tick();
+ f.deliver({ id: "old-live", kind: 50182 });
+ rejectHistory(Error("Timed out while loading channel history."));
+ await assert.rejects(failed, /history: history timed out/);
+ f.relay.fetchEvents = async () => [{ id: "old-live", kind: 50182 }];
+ const close = await receiveLifecycle(
+ scope,
+ () => true,
+ () => {},
+ f.ipc,
+ f.relay,
+ );
+ await tick();
+ assert.equal(
+ f.calls.filter(([c]) => c === "receive_desktop_lifecycle").length,
+ 0,
+ );
+ f.deliver({ id: "new-live", kind: 50182 });
+ await tick();
+ assert.equal(
+ f.calls.filter(([c]) => c === "receive_desktop_lifecycle").length,
+ 1,
+ );
+ f.deliver({ id: "closed-queue", kind: 50182 });
+ close();
+ await tick();
+ assert.equal(
+ f.calls.filter(([c]) => c === "receive_desktop_lifecycle").length,
+ 1,
+ );
+});
+
+test("readiness timeout is distinct, late EOSE recovers, CLOSED retires old callbacks", async () => {
+ const f = fixture();
+ let notify, deliver;
+ let closed = 0,
+ ready = 0;
+ f.relay.subscribeLive = async (_filter, event, onReady, timeout) => {
+ assert.equal(timeout, 5000);
+ deliver = event;
+ notify = onReady;
+ notify("timeout");
+ return () => {
+ closed++;
+ };
+ };
+ const close = await receiveLifecycle(
+ scope,
+ () => true,
+ (e) => f.errors.push(e),
+ f.ipc,
+ f.relay,
+ () => ready++,
+ );
+ assert.match(f.errors[0], /readiness timed out/);
+ assert.equal(ready, 0);
+ notify("eose");
+ assert.equal(ready, 1);
+ notify("closed");
+ deliver({ id: "late", kind: 50182 });
+ await tick();
+ assert.equal(closed, 1);
+ assert.equal(
+ f.calls.filter(([c]) => c === "receive_desktop_lifecycle").length,
+ 0,
+ );
+ assert.match(f.errors.at(-1), /subscription closed/);
+ close();
+});
diff --git a/desktop/src/features/agents/desktopLifecycle.ts b/desktop/src/features/agents/desktopLifecycle.ts
index 7af772aa181..1fb321c67ac 100644
--- a/desktop/src/features/agents/desktopLifecycle.ts
+++ b/desktop/src/features/agents/desktopLifecycle.ts
@@ -9,6 +9,11 @@ import {
sendStop,
} from "./desktopStop";
+import {
+ LifecycleReceiverError,
+ receiverStep,
+} from "./desktopLifecycleDiagnostics";
+
export const DESKTOP_LIFECYCLE = 50182;
export const DESKTOP_LIFECYCLE_RESULT = 50183;
export type LifecycleAction = "start" | "restart" | "status";
@@ -99,27 +104,33 @@ export function lifecycleClient(
let before_id: string | undefined;
for (let page = 0; page < 64; page++) {
check();
- const events = await relay.fetchEvents({
- kinds: [DESKTOP_STOP, DESKTOP_LIFECYCLE],
- authors: [scope.owner],
- limit: 256,
- until,
- before_id,
- });
+ const events = await receiverStep("history", () =>
+ relay.fetchEvents({
+ kinds: [DESKTOP_STOP, DESKTOP_LIFECYCLE],
+ authors: [scope.owner],
+ limit: 256,
+ until,
+ before_id,
+ }),
+ );
check();
// No effects while a partial page could still hide a dominating Start.
- await ipc("observe_desktop_placement", {
- ...scope,
- events,
- reconcile: false,
- });
+ await receiverStep("projection", () =>
+ ipc("observe_desktop_placement", {
+ ...scope,
+ events,
+ reconcile: false,
+ }),
+ );
check();
if (events.length < 256) {
- await ipc("observe_desktop_placement", {
- ...scope,
- events: [],
- reconcile: true,
- });
+ await receiverStep("reconciliation", () =>
+ ipc("observe_desktop_placement", {
+ ...scope,
+ events: [],
+ reconcile: true,
+ }),
+ );
check();
return;
}
@@ -250,14 +261,21 @@ export function lifecycleClient(
}
/** Subscribe first, then project history; live commands wait for complete
- * initialization. Reconnect gets a new client epoch and never replays history. */
+ * initialization. Explicit receiver retry starts a fresh live-only subscription,
+ * never retries an operation or executes historical commands. */
export async function receiveLifecycle(
scope: DesktopScope,
active: () => boolean,
onError: (message: string) => void,
ipc = invoke,
relay = relayClient,
+ onReady: () => void = () => {},
) {
+ let stopped = false;
+ let stopSubscription = () => {};
+ let synced = false;
+ let subscriptionReady = false;
+ const valid = () => active() && !stopped;
let client: ReturnType;
let initialized: () => void = () => {};
const ready = new Promise((resolve) => {
@@ -265,70 +283,99 @@ export async function receiveLifecycle(
});
let chain = Promise.resolve();
let pending = 0;
- const close = await relay.subscribeLive(
- {
- kinds: [DESKTOP_LIFECYCLE, DESKTOP_STOP],
- authors: [scope.owner],
- limit: 0,
- },
- (event) => {
- if (!active()) return;
- if (pending >= 16) {
- onError("Desktop lifecycle receiver is busy; outcome is unconfirmed.");
- return;
- }
- pending++;
- chain = chain
- .then(async () => {
- await ready;
- client.check();
- await ipc("observe_desktop_placement", {
- ...scope,
- events: [event],
- reconcile: true,
- });
- client.check();
- const result = await ipc(
- event.kind === DESKTOP_STOP
- ? "receive_desktop_stop"
- : "receive_desktop_lifecycle",
- { ...scope, event },
+ const unsubscribe = await receiverStep("subscription", () =>
+ relay.subscribeLive(
+ {
+ kinds: [DESKTOP_LIFECYCLE, DESKTOP_STOP],
+ authors: [scope.owner],
+ limit: 0,
+ },
+ (event) => {
+ if (!valid()) return;
+ if (pending >= 16) {
+ onError(
+ "Desktop lifecycle receiver is busy; outcome is unconfirmed.",
);
- client.check();
- if (result)
- await relay.publishEvent(
- result,
- "Result delivery unconfirmed",
- "Result delivery failed",
- client.check,
+ return;
+ }
+ pending++;
+ chain = chain
+ .then(async () => {
+ await ready;
+ if (!valid()) return;
+ client.check();
+ await ipc("observe_desktop_placement", {
+ ...scope,
+ events: [event],
+ reconcile: true,
+ });
+ client.check();
+ const result = await ipc(
+ event.kind === DESKTOP_STOP
+ ? "receive_desktop_stop"
+ : "receive_desktop_lifecycle",
+ { ...scope, event },
);
- })
- .catch(() => {
- if (active())
- onError(
- "Desktop lifecycle result is unconfirmed. No automatic operation retry.",
- );
- })
- .finally(() => {
- pending--;
- });
- },
- (readiness) => {
- if (active() && readiness !== "eose")
- onError("Desktop lifecycle receiver is unavailable.");
- },
+ client.check();
+ if (result)
+ await relay.publishEvent(
+ result,
+ "Result delivery unconfirmed",
+ "Result delivery failed",
+ client.check,
+ );
+ })
+ .catch(() => {
+ if (valid())
+ onError(
+ "Desktop lifecycle result is unconfirmed. No automatic operation retry.",
+ );
+ })
+ .finally(() => {
+ pending--;
+ });
+ },
+ (readiness) => {
+ if (!valid()) return;
+ subscriptionReady = readiness === "eose";
+ if (readiness === "closed") {
+ stopped = true;
+ stopSubscription();
+ onError(
+ "Desktop lifecycle receiver subscription closed. Retry the receiver to accept new requests.",
+ );
+ } else if (readiness === "timeout") {
+ onError(
+ "Desktop lifecycle subscription readiness timed out. Delivery is unconfirmed.",
+ );
+ } else if (synced) onReady();
+ },
+ 5000,
+ ),
);
- client = lifecycleClient(scope, active, ipc, relay);
+ const close = () => {
+ stopped = true;
+ // Unsubscribe may fail on a dead socket; it must not revive this receiver
+ // or leave an unhandled promise. A retry always owns a new subscription.
+ void Promise.resolve()
+ .then(unsubscribe)
+ .catch(() => {});
+ };
+ stopSubscription = close;
+ client = lifecycleClient(scope, valid, ipc, relay);
try {
+ if (stopped) throw new LifecycleReceiverError("subscription", "closed");
await client.sync();
client.check();
+ synced = true;
initialized();
+ if (subscriptionReady) onReady();
} catch (error) {
close();
- // Release queued callbacks into a permanently invalidated client.
- client = lifecycleClient(scope, () => false, ipc, relay);
initialized();
- throw error;
+ throw error instanceof LifecycleReceiverError
+ ? error
+ : new LifecycleReceiverError("initialization", error);
}
return close;
}
diff --git a/desktop/src/features/agents/desktopLifecycleDiagnostics.ts b/desktop/src/features/agents/desktopLifecycleDiagnostics.ts
new file mode 100644
index 00000000000..aebe01c41a9
--- /dev/null
+++ b/desktop/src/features/agents/desktopLifecycleDiagnostics.ts
@@ -0,0 +1,41 @@
+/** Safe receiver diagnostics: raw IPC/transport errors can contain private data. */
+type Stage =
+ | "initialization"
+ | "subscription"
+ | "history"
+ | "projection"
+ | "reconciliation";
+
+export class LifecycleReceiverError extends Error {
+ constructor(stage: Stage, error: unknown) {
+ const value = error instanceof Error ? error.message : error;
+ const reason =
+ value === "closed"
+ ? "subscription closed"
+ : value === "Desktop lifecycle scope changed"
+ ? "scope changed"
+ : value === "Relay session is terminal; cannot reconnect."
+ ? "relay session requires reconnection"
+ : value === "Timed out while loading channel history."
+ ? "history timed out"
+ : "request failed";
+ super(`Desktop lifecycle receiver is unavailable (${stage}: ${reason}).`);
+ }
+}
+
+export function receiverErrorMessage(error: unknown): string {
+ return error instanceof LifecycleReceiverError
+ ? error.message
+ : "Desktop lifecycle receiver is unavailable (initialization failed).";
+}
+
+export async function receiverStep(
+ stage: Stage,
+ action: () => Promise,
+): Promise {
+ try {
+ return await action();
+ } catch (error) {
+ throw new LifecycleReceiverError(stage, error);
+ }
+}
diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs
index f41068c6136..3ac174ac536 100644
--- a/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs
+++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs
@@ -157,6 +157,8 @@ test("receiver failure is a scope-owned notification, not pre-shell layout", asy
let closed = 0;
let rejectLate;
let delayed = false;
+ let storageAvailable = false;
+ let subscribed = 0;
relayClient.fetchEvents = async () => {
if (delayed)
return new Promise((_, reject) => {
@@ -165,13 +167,16 @@ test("receiver failure is a scope-owned notification, not pre-shell layout", asy
return [];
};
relayClient.subscribeLive = async (_filter, _event, onReadiness) => {
+ subscribed++;
readiness = onReadiness;
+ onReadiness("eose");
return () => {
closed++;
};
};
window.__TAURI_INTERNALS__ = {
invoke: async () => {
+ if (storageAvailable) return;
throw new Error("fixture: storage unavailable");
},
};
@@ -193,7 +198,7 @@ test("receiver failure is a scope-owned notification, not pre-shell layout", asy
assert.equal(warnings().length, 1);
assert.equal(
warnings()[0].title,
- "Desktop lifecycle receiver is unavailable.",
+ "Desktop lifecycle receiver is unavailable (projection: request failed).",
);
assert.equal(warnings()[0].duration, Infinity);
assert.equal(warnings()[0].closeButton, true);
@@ -203,6 +208,16 @@ test("receiver failure is a scope-owned notification, not pre-shell layout", asy
1,
"repeated failures update one notification",
);
+ const retryAction = warnings()[0].action;
+ assert.equal(retryAction.label, "Retry receiver");
+ storageAvailable = true;
+ await React.act(async () => retryAction.onClick());
+ assert.equal(subscribed, 2, "explicit recovery starts a new live receiver");
+ assert.equal(
+ warnings().length,
+ 0,
+ "successful recovery removes its warning",
+ );
await React.act(async () =>
root.render(
React.createElement(DesktopLifecycleReceiver, { scope: null }),
@@ -227,7 +242,11 @@ test("receiver failure is a scope-owned notification, not pre-shell layout", asy
0,
"late startup rejection must not recreate the warning",
);
- assert.equal(closed, 2, "both failed subscriptions are released");
+ assert.equal(
+ closed,
+ 3,
+ "failed, recovered and retired subscriptions are released",
+ );
} finally {
await React.act(async () => root.unmount());
relayClient.fetchEvents = originals.fetch;
diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx
index d05efee2511..0b6b63d67be 100644
--- a/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx
+++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx
@@ -9,6 +9,7 @@ import {
type LifecycleOutcome,
} from "../desktopLifecycle";
import { useRelayAgentsQuery } from "../hooks";
+import { receiverErrorMessage } from "../desktopLifecycleDiagnostics";
export function DesktopLifecycleReceiver({
scope,
@@ -16,6 +17,7 @@ export function DesktopLifecycleReceiver({
scope: DesktopScope | null;
}) {
const { owner, community } = scope ?? {};
+ const [attempt, retry] = useState(0);
useEffect(() => {
if (!owner || !community) return;
let active = true;
@@ -30,22 +32,40 @@ export function DesktopLifecycleReceiver({
id: notification,
duration: Infinity,
closeButton: true,
+ action: {
+ label: "Retry receiver",
+ onClick: () => {
+ if (!active) return;
+ active = false;
+ close?.();
+ retry(attempt + 1);
+ },
+ },
});
};
- void receiveLifecycle({ owner, community }, () => active, reportError)
+ void receiveLifecycle(
+ { owner, community },
+ () => active,
+ reportError,
+ undefined,
+ undefined,
+ () => {
+ if (active && notification !== undefined) toast.dismiss(notification);
+ },
+ )
.then((fn) => {
if (active) close = fn;
else fn();
})
- .catch(() => {
- reportError("Desktop lifecycle receiver is unavailable.");
+ .catch((error) => {
+ reportError(receiverErrorMessage(error));
});
return () => {
active = false;
close?.();
if (notification !== undefined) toast.dismiss(notification);
};
- }, [owner, community]);
+ }, [owner, community, attempt]);
return null;
}
function message(outcome: LifecycleOutcome) {
diff --git a/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts b/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts
index 1d4d6795a76..97d6b69cb1f 100644
--- a/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts
+++ b/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts
@@ -118,9 +118,7 @@ test.describe("top chrome macOS traffic-light clearance under text zoom", () =>
// A failed global receiver must remain visible without entering the shell's
// layout flow. This also forces the error to settle before measuring chrome.
await expect(
- page.getByText("Desktop lifecycle receiver is unavailable.", {
- exact: true,
- }),
+ page.getByText(/Desktop lifecycle receiver is unavailable/),
).toBeVisible();
// Lock the native and webview placements together: removing this explicit
From 18258f91d5362e64d3413124290969e7bcd943a9 Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 23:03:24 -0400
Subject: [PATCH 27/51] fix(desktop): observe receiver closure after
subscription readiness
Signed-off-by: Logan Johnson
---
.../features/agents/desktopLifecycle.test.mjs | 11 +-
.../src/features/agents/desktopLifecycle.ts | 34 ++-
.../ui/DesktopLifecycleControl.test.mjs | 12 +-
desktop/src/shared/api/relayClientSession.ts | 25 +-
desktop/src/shared/api/relayClientShared.ts | 15 +
desktop/src/shared/api/relayClosedRecovery.ts | 17 +-
.../api/relayLiveSubscriptionState.test.mjs | 268 ++++++++++++++++++
7 files changed, 355 insertions(+), 27 deletions(-)
create mode 100644 desktop/src/shared/api/relayLiveSubscriptionState.test.mjs
diff --git a/desktop/src/features/agents/desktopLifecycle.test.mjs b/desktop/src/features/agents/desktopLifecycle.test.mjs
index 33de28d85ba..2a0ff0f314d 100644
--- a/desktop/src/features/agents/desktopLifecycle.test.mjs
+++ b/desktop/src/features/agents/desktopLifecycle.test.mjs
@@ -342,10 +342,17 @@ test("readiness timeout is distinct, late EOSE recovers, CLOSED retires old call
let notify, deliver;
let closed = 0,
ready = 0;
- f.relay.subscribeLive = async (_filter, event, onReady, timeout) => {
+ f.relay.subscribeLive = async (
+ _filter,
+ event,
+ _onReady,
+ timeout,
+ options,
+ ) => {
assert.equal(timeout, 5000);
deliver = event;
- notify = onReady;
+ assert.equal(options.closedRecovery, "explicit");
+ notify = options.onState;
notify("timeout");
return () => {
closed++;
diff --git a/desktop/src/features/agents/desktopLifecycle.ts b/desktop/src/features/agents/desktopLifecycle.ts
index 1fb321c67ac..b81e4826052 100644
--- a/desktop/src/features/agents/desktopLifecycle.ts
+++ b/desktop/src/features/agents/desktopLifecycle.ts
@@ -335,22 +335,26 @@ export async function receiveLifecycle(
pending--;
});
},
- (readiness) => {
- if (!valid()) return;
- subscriptionReady = readiness === "eose";
- if (readiness === "closed") {
- stopped = true;
- stopSubscription();
- onError(
- "Desktop lifecycle receiver subscription closed. Retry the receiver to accept new requests.",
- );
- } else if (readiness === "timeout") {
- onError(
- "Desktop lifecycle subscription readiness timed out. Delivery is unconfirmed.",
- );
- } else if (synced) onReady();
- },
+ undefined,
5000,
+ {
+ closedRecovery: "explicit",
+ onState: (readiness) => {
+ if (!valid()) return;
+ subscriptionReady = readiness === "eose";
+ if (readiness === "closed") {
+ stopped = true;
+ stopSubscription();
+ onError(
+ "Desktop lifecycle receiver subscription closed. Retry the receiver to accept new requests.",
+ );
+ } else if (readiness === "timeout") {
+ onError(
+ "Desktop lifecycle subscription readiness timed out. Delivery is unconfirmed.",
+ );
+ } else if (synced) onReady();
+ },
+ },
),
);
const close = () => {
diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs
index 3ac174ac536..35e52f7aab0 100644
--- a/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs
+++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs
@@ -166,10 +166,16 @@ test("receiver failure is a scope-owned notification, not pre-shell layout", asy
});
return [];
};
- relayClient.subscribeLive = async (_filter, _event, onReadiness) => {
+ relayClient.subscribeLive = async (
+ _filter,
+ _event,
+ _onReadiness,
+ _timeout,
+ options,
+ ) => {
subscribed++;
- readiness = onReadiness;
- onReadiness("eose");
+ readiness = options.onState;
+ readiness("eose");
return () => {
closed++;
};
diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts
index 2f94a65bf9b..a7c0bded02d 100644
--- a/desktop/src/shared/api/relayClientSession.ts
+++ b/desktop/src/shared/api/relayClientSession.ts
@@ -16,6 +16,7 @@ import {
getTextPayload,
toRelayFrames,
type ConnectionState,
+ type LiveSubscriptionOptions,
type LiveSubscriptionReadiness,
type PendingEvent,
type RelaySubscription,
@@ -419,8 +420,15 @@ export class RelayClient {
onEvent: (event: RelayEvent) => void,
onReady?: (readiness: LiveSubscriptionReadiness) => void,
readinessTimeoutMs?: number,
+ options?: LiveSubscriptionOptions,
) {
- return this.subscribe(filter, onEvent, onReady, readinessTimeoutMs);
+ return this.subscribe(
+ filter,
+ onEvent,
+ onReady,
+ readinessTimeoutMs,
+ options,
+ );
}
async subscribeToChannelMentionEvents(
channelId: string,
@@ -609,6 +617,7 @@ export class RelayClient {
onEvent: (event: RelayEvent) => void,
onReady?: (readiness: LiveSubscriptionReadiness) => void,
readinessTimeoutMs = 250,
+ options: LiveSubscriptionOptions = {},
) {
await this.ensureConnected();
@@ -621,16 +630,18 @@ export class RelayClient {
resolve();
};
});
- const fallbackTimeout = window.setTimeout(
- () => resolveReady("timeout"),
- readinessTimeoutMs,
- );
+ const fallbackTimeout = window.setTimeout(() => {
+ options.onState?.("timeout");
+ resolveReady("timeout");
+ }, readinessTimeoutMs);
this.subscriptions.set(subId, {
mode: "live",
filter,
onEvent,
resolveReady,
+ onState: options.onState,
+ closedRecovery: options.closedRecovery ?? "shared",
});
try {
@@ -1066,8 +1077,12 @@ export class RelayClient {
continue;
}
subscription.resolveReady?.("closed");
+ subscription.onState?.("closed");
subscription.resolveReady = undefined;
clearClosedRetry(subscription);
+ if (subscription.closedRecovery === "explicit") {
+ this.subscriptions.delete(subId);
+ }
}
for (const [eventId, pendingEvent] of this.pendingEvents) {
window.clearTimeout(pendingEvent.timeout);
diff --git a/desktop/src/shared/api/relayClientShared.ts b/desktop/src/shared/api/relayClientShared.ts
index 8bd6379d7a9..925c74b573d 100644
--- a/desktop/src/shared/api/relayClientShared.ts
+++ b/desktop/src/shared/api/relayClientShared.ts
@@ -70,11 +70,26 @@ type FirstEventSubscription = {
export type LiveSubscriptionReadiness = "eose" | "closed" | "timeout";
+/**
+ * Optional lifecycle policy for a live subscription.
+ *
+ * Most subscriptions keep the shared reconnect/CLOSED recovery behavior. A
+ * command receiver can instead request explicit recovery: every CLOSED retires
+ * that subscription and `onState` remains observable after initial EOSE so the
+ * owning UI can offer a deliberate fresh subscription.
+ */
+export type LiveSubscriptionOptions = {
+ onState?: (state: LiveSubscriptionReadiness) => void;
+ closedRecovery?: "shared" | "explicit";
+};
+
type LiveSubscription = {
mode: "live";
filter: RelaySubscriptionFilter;
onEvent: (event: RelayEvent) => void;
resolveReady?: (readiness: LiveSubscriptionReadiness) => void;
+ onState?: (state: LiveSubscriptionReadiness) => void;
+ closedRecovery: "shared" | "explicit";
lastSeenCreatedAt?: number;
/**
* Lower bound of a reconnect backfill window that has not yet completed.
diff --git a/desktop/src/shared/api/relayClosedRecovery.ts b/desktop/src/shared/api/relayClosedRecovery.ts
index 9d30a233e7b..8f1a68287d2 100644
--- a/desktop/src/shared/api/relayClosedRecovery.ts
+++ b/desktop/src/shared/api/relayClosedRecovery.ts
@@ -124,10 +124,24 @@ function recoverLiveSubscriptionFromClosed({
sendReq: (subId: string, filter: RelaySubscriptionFilter) => Promise;
}) {
subscription.resolveReady?.("closed");
+ subscription.onState?.("closed");
subscription.resolveReady = undefined;
const closedClass = classifyRelayClosed(message);
+ if (closedClass === "rate-limited") {
+ const hintSeconds = parseRateLimitHint(message);
+ activateRateLimit(hintSeconds);
+ }
+
+ if (subscription.closedRecovery === "explicit") {
+ // Command receivers must not survive CLOSED into shared re-subscription.
+ // Their owner presents an explicit fresh-receiver action instead.
+ clearClosedRetry(subscription);
+ subscriptions.delete(subId);
+ return;
+ }
+
if (closedClass === "terminal") {
// Auth/access/filter failure — permanently remove the subscription so it
// doesn't silently loop.
@@ -146,9 +160,7 @@ function recoverLiveSubscriptionFromClosed({
let delayMs = backoffMs;
if (closedClass === "rate-limited") {
- // Activate the gate so concurrent operations back off too.
const hintSeconds = parseRateLimitHint(message);
- activateRateLimit(hintSeconds);
// Use the gate's actual remaining time so a shorter hint arriving under a
// longer active gate does not schedule a premature retry that just gets
// another CLOSED. The fallback covers the gate-inactive edge case
@@ -259,6 +271,7 @@ export function handleSubscriptionEose({
if (generation !== undefined)
markReconnectLiveEose(subscription, generation);
subscription.resolveReady?.("eose");
+ subscription.onState?.("eose");
subscription.resolveReady = undefined;
subscription.closedRetryAttempt = 0;
clearClosedRetry(subscription);
diff --git a/desktop/src/shared/api/relayLiveSubscriptionState.test.mjs b/desktop/src/shared/api/relayLiveSubscriptionState.test.mjs
new file mode 100644
index 00000000000..587bfae1f28
--- /dev/null
+++ b/desktop/src/shared/api/relayLiveSubscriptionState.test.mjs
@@ -0,0 +1,268 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+let fakeNow = 0;
+let nextTimerId = 1;
+const pendingTimers = new Map();
+const sentFrames = [];
+
+globalThis.window = {
+ setTimeout: (fn, ms) => {
+ const id = nextTimerId++;
+ pendingTimers.set(id, { fn, fireAt: fakeNow + ms });
+ return id;
+ },
+ clearTimeout: (id) => pendingTimers.delete(id),
+ __TAURI_INTERNALS__: {
+ invoke: async (command, args) => {
+ if (command === "plugin:websocket|send") sentFrames.push(args);
+ },
+ },
+};
+Date.now = () => fakeNow;
+
+const { RelayClient } = await import("./relayClientSession.ts");
+const { receiveLifecycle } = await import("@/features/agents/desktopLifecycle");
+const { resetRateLimitGate } = await import("./relayRateLimitGate.ts");
+
+function resetHarness() {
+ fakeNow = 0;
+ nextTimerId = 1;
+ pendingTimers.clear();
+ sentFrames.length = 0;
+ resetRateLimitGate();
+}
+
+function connectedClient() {
+ const client = new RelayClient();
+ client.wsId = 7;
+ return client;
+}
+
+function sentProtocolFrames(type) {
+ return sentFrames
+ .map(({ message }) => JSON.parse(message.data))
+ .filter((frame) => frame[0] === type);
+}
+
+async function flushUntil(predicate, attempts = 40) {
+ for (let attempt = 0; attempt < attempts; attempt++) {
+ if (predicate()) return;
+ await Promise.resolve();
+ }
+ assert.fail("condition did not become true before the microtask limit");
+}
+
+async function flushMicrotasks(attempts = 10) {
+ for (let attempt = 0; attempt < attempts; attempt++) await Promise.resolve();
+}
+
+function tickTo(time) {
+ fakeNow = time;
+ for (;;) {
+ const due = [...pendingTimers.entries()].filter(
+ ([, timer]) => timer.fireAt <= fakeNow,
+ );
+ if (!due.length) return;
+ for (const [id, timer] of due) {
+ if (!pendingTimers.delete(id)) continue;
+ timer.fn();
+ }
+ }
+}
+
+function deliver(client, frame) {
+ return client.handleWsMessage(
+ { type: "Text", data: JSON.stringify(frame) },
+ client.connectionGeneration,
+ );
+}
+
+async function openLive(client, options, onEvent = () => {}, onReady) {
+ const opened = client.subscribeLive(
+ { kinds: [50182, 50180], authors: ["owner"], limit: 0 },
+ onEvent,
+ onReady,
+ 5000,
+ options,
+ );
+ await flushUntil(() => sentProtocolFrames("REQ").length > 0);
+ const subId = sentProtocolFrames("REQ").at(-1)[1];
+ return { opened, subId };
+}
+
+test("persistent state reports timeout, late EOSE, then CLOSED through RelayClient", async () => {
+ resetHarness();
+ const client = connectedClient();
+ const states = [];
+ const readiness = [];
+ const { opened, subId } = await openLive(
+ client,
+ { closedRecovery: "explicit", onState: (state) => states.push(state) },
+ () => {},
+ (state) => readiness.push(state),
+ );
+
+ tickTo(5000);
+ const close = await opened;
+ assert.deepEqual(states, ["timeout"]);
+ assert.deepEqual(readiness, ["timeout"]);
+
+ await deliver(client, ["EOSE", subId]);
+ await deliver(client, ["CLOSED", subId, "restricted: access revoked"]);
+
+ assert.deepEqual(states, ["timeout", "eose", "closed"]);
+ assert.deepEqual(readiness, ["timeout", "eose"]);
+ assert.equal(client.subscriptions.has(subId), false);
+ await close();
+});
+
+for (const [label, message] of [
+ ["terminal", "restricted: access revoked"],
+ ["retryable", "error: storage temporarily unavailable"],
+]) {
+ test(`explicit recovery retires an EOSE-ready ${label} CLOSED without re-REQ`, async () => {
+ resetHarness();
+ const client = connectedClient();
+ const states = [];
+ const { opened, subId } = await openLive(client, {
+ closedRecovery: "explicit",
+ onState: (state) => states.push(state),
+ });
+ await deliver(client, ["EOSE", subId]);
+ const close = await opened;
+
+ await deliver(client, ["CLOSED", subId, message]);
+ tickTo(60_000);
+ await Promise.resolve();
+
+ assert.deepEqual(states, ["eose", "closed"]);
+ assert.equal(client.subscriptions.has(subId), false);
+ assert.equal(sentProtocolFrames("REQ").length, 1);
+ await close();
+ });
+}
+
+test("connection reset reports CLOSED and retires only explicit-recovery subscriptions", async () => {
+ resetHarness();
+ const client = connectedClient();
+ const explicitStates = [];
+ const explicit = await openLive(client, {
+ closedRecovery: "explicit",
+ onState: (state) => explicitStates.push(state),
+ });
+ await deliver(client, ["EOSE", explicit.subId]);
+ const closeExplicit = await explicit.opened;
+
+ const ordinary = await openLive(client);
+ await deliver(client, ["EOSE", ordinary.subId]);
+ const closeOrdinary = await ordinary.opened;
+
+ client.resetConnection(new Error("fixture connection reset"));
+
+ assert.deepEqual(explicitStates, ["eose", "closed"]);
+ assert.equal(client.subscriptions.has(explicit.subId), false);
+ assert.equal(
+ client.subscriptions.has(ordinary.subId),
+ true,
+ "default live subscribers retain shared reconnect recovery",
+ );
+
+ await closeExplicit();
+ await closeOrdinary();
+ client.disconnect();
+});
+
+test("default live subscribers retain shared retry after retryable CLOSED", async () => {
+ resetHarness();
+ const client = connectedClient();
+ const ordinary = await openLive(client);
+ await deliver(client, ["EOSE", ordinary.subId]);
+ const close = await ordinary.opened;
+
+ await deliver(client, [
+ "CLOSED",
+ ordinary.subId,
+ "error: storage temporarily unavailable",
+ ]);
+ assert.equal(client.subscriptions.has(ordinary.subId), true);
+
+ tickTo(1000);
+ await flushUntil(() => sentProtocolFrames("REQ").length === 2);
+ assert.equal(client.subscriptions.has(ordinary.subId), true);
+
+ await close();
+});
+
+test("real explicit CLOSED fences queued lifecycle work and a deliberate retry is fresh", async () => {
+ resetHarness();
+ const client = connectedClient();
+ let resolveHistory;
+ client.fetchEvents = () =>
+ new Promise((resolve) => {
+ resolveHistory = resolve;
+ });
+ const ipcCalls = [];
+ const ipc = async (command) => {
+ ipcCalls.push(command);
+ return null;
+ };
+ const errors = [];
+ const receiving = receiveLifecycle(
+ { owner: "owner", community: "wss://one.example" },
+ () => true,
+ (error) => errors.push(error),
+ ipc,
+ client,
+ );
+
+ await flushUntil(() => sentProtocolFrames("REQ").length === 1);
+ const retiredSubId = sentProtocolFrames("REQ")[0][1];
+ await deliver(client, ["EOSE", retiredSubId]);
+ await flushUntil(() => resolveHistory !== undefined);
+ await deliver(client, [
+ "EVENT",
+ retiredSubId,
+ { id: "queued-old", kind: 50182, created_at: 1 },
+ ]);
+ tickTo(20);
+ await deliver(client, ["CLOSED", retiredSubId, "restricted: access revoked"]);
+ resolveHistory([]);
+
+ await assert.rejects(receiving, /receiver is unavailable/);
+ await flushMicrotasks();
+ assert.equal(
+ ipcCalls.includes("receive_desktop_lifecycle"),
+ false,
+ "queued work from the retired receiver must not execute",
+ );
+ assert.match(errors.at(-1), /subscription closed/);
+
+ client.fetchEvents = async () => [];
+ const retried = receiveLifecycle(
+ { owner: "owner", community: "wss://one.example" },
+ () => true,
+ (error) => errors.push(error),
+ ipc,
+ client,
+ );
+ await flushUntil(() => sentProtocolFrames("REQ").length === 2);
+ const freshSubId = sentProtocolFrames("REQ")[1][1];
+ assert.notEqual(freshSubId, retiredSubId);
+ await deliver(client, ["EOSE", freshSubId]);
+ const close = await retried;
+
+ await deliver(client, [
+ "EVENT",
+ freshSubId,
+ { id: "new-live", kind: 50182, created_at: 2 },
+ ]);
+ tickTo(40);
+ await flushUntil(() => ipcCalls.includes("receive_desktop_lifecycle"));
+ assert.equal(
+ ipcCalls.filter((command) => command === "receive_desktop_lifecycle")
+ .length,
+ 1,
+ );
+ await close();
+});
From 6bea46f78c5d5722e9162641d310bdd80203793a Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Fri, 4 Sep 2026 23:40:53 -0400
Subject: [PATCH 28/51] fix(desktop): bound lifecycle receiver recovery without
command replay
Signed-off-by: Logan Johnson
---
desktop/src/features/agents/AGENTS.md | 10 +-
.../src/features/agents/desktopLifecycle.ts | 18 +-
.../agents/desktopLifecycleReceiver.test.mjs | 443 ++++++++++++++++++
.../agents/desktopLifecycleReceiver.ts | 142 ++++++
.../ui/DesktopLifecycleControl.test.mjs | 19 +-
.../agents/ui/DesktopLifecycleControl.tsx | 34 +-
desktop/src/shared/api/relayClientSession.ts | 25 +-
desktop/src/shared/api/relayClientShared.ts | 22 +-
desktop/src/shared/api/relayClosedRecovery.ts | 11 +-
.../api/relayLiveSubscriptionState.test.mjs | 132 +++++-
.../e2e/top-chrome-zoom-clearance.spec.ts | 5 +-
11 files changed, 800 insertions(+), 61 deletions(-)
create mode 100644 desktop/src/features/agents/desktopLifecycleReceiver.test.mjs
create mode 100644 desktop/src/features/agents/desktopLifecycleReceiver.ts
diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md
index 2589afefb4d..e48401b4765 100644
--- a/desktop/src/features/agents/AGENTS.md
+++ b/desktop/src/features/agents/AGENTS.md
@@ -313,9 +313,13 @@ with a TypeScript lookup table or an id comparison in a component.
Known Desktops exposes an owner-private, explicitly selected agent+Desktop Stop,
not inferred agent location. The app-scoped receiver subscribes live only;
reopening never replays commands. Receiver initialization reports a safe failure
-stage without exposing raw transport/IPC exceptions. Its scope-owned notification
-can explicitly retry the **receiver** with a fresh live-only subscription; that
-must discard queued callbacks from the retired receiver, not retry an operation.
+stage without exposing raw transport/IPC exceptions. Transient initialization
+failures and transient CLOSED states recover through a bounded receiver-owner
+budget; each attempt uses a fresh live-only subscription and repeats
+projection-only sync before admission. Terminal closure or exhausted recovery
+stays in the scope-owned notification, whose deliberate retry resets the receiver
+budget. Recovery must discard queued callbacks from the retired receiver, not
+retry an operation, and must respect the relay rate-limit gate.
A readiness timeout is unconfirmed delivery, not a failed initialization; late
EOSE clears that warning after successful projection. An explicit operation retry republishes the exact request;
the relay redelivers stored Stop duplicates without repeating relay side effects.
diff --git a/desktop/src/features/agents/desktopLifecycle.ts b/desktop/src/features/agents/desktopLifecycle.ts
index b81e4826052..c5f00cafe2b 100644
--- a/desktop/src/features/agents/desktopLifecycle.ts
+++ b/desktop/src/features/agents/desktopLifecycle.ts
@@ -1,5 +1,6 @@
import { invoke } from "@tauri-apps/api/core";
import { relayClient } from "@/shared/api/relayClient";
+import type { LiveSubscriptionClosedRecovery } from "@/shared/api/relayClientShared";
import type { RelayEvent } from "@/shared/api/types";
import type { DesktopScope } from "./desktopList";
import {
@@ -270,8 +271,10 @@ export async function receiveLifecycle(
ipc = invoke,
relay = relayClient,
onReady: () => void = () => {},
+ onClosed?: (recovery: LiveSubscriptionClosedRecovery) => void,
) {
let stopped = false;
+ let released = false;
let stopSubscription = () => {};
let synced = false;
let subscriptionReady = false;
@@ -339,15 +342,20 @@ export async function receiveLifecycle(
5000,
{
closedRecovery: "explicit",
- onState: (readiness) => {
+ onState: (readiness, closed) => {
if (!valid()) return;
subscriptionReady = readiness === "eose";
if (readiness === "closed") {
stopped = true;
stopSubscription();
- onError(
- "Desktop lifecycle receiver subscription closed. Retry the receiver to accept new requests.",
- );
+ if (onClosed)
+ onClosed(
+ closed ?? { classification: "terminal", retryAfterMs: 0 },
+ );
+ else
+ onError(
+ "Desktop lifecycle receiver subscription closed. Retry the receiver to accept new requests.",
+ );
} else if (readiness === "timeout") {
onError(
"Desktop lifecycle subscription readiness timed out. Delivery is unconfirmed.",
@@ -358,6 +366,8 @@ export async function receiveLifecycle(
),
);
const close = () => {
+ if (released) return;
+ released = true;
stopped = true;
// Unsubscribe may fail on a dead socket; it must not revive this receiver
// or leave an unhandled promise. A retry always owns a new subscription.
diff --git a/desktop/src/features/agents/desktopLifecycleReceiver.test.mjs b/desktop/src/features/agents/desktopLifecycleReceiver.test.mjs
new file mode 100644
index 00000000000..70b96890052
--- /dev/null
+++ b/desktop/src/features/agents/desktopLifecycleReceiver.test.mjs
@@ -0,0 +1,443 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { receiveLifecycle } from "./desktopLifecycle.ts";
+import {
+ ownLifecycleReceiver,
+ RECEIVER_RECOVERY_DELAYS_MS,
+} from "./desktopLifecycleReceiver.ts";
+
+const scope = { owner: "owner", community: "wss://one.example" };
+
+async function flushUntil(predicate, attempts = 40) {
+ for (let attempt = 0; attempt < attempts; attempt++) {
+ if (predicate()) return;
+ await Promise.resolve();
+ }
+ assert.fail("condition did not become true before the microtask limit");
+}
+
+function timers() {
+ let nextId = 1;
+ const pending = new Map();
+ return {
+ setTimer(callback, delayMs) {
+ const id = nextId++;
+ pending.set(id, { callback, delayMs });
+ return id;
+ },
+ clearTimer(id) {
+ pending.delete(id);
+ },
+ fireNext() {
+ const entry = pending.entries().next().value;
+ assert.ok(entry, "expected a pending recovery timer");
+ pending.delete(entry[0]);
+ entry[1].callback();
+ },
+ pending,
+ };
+}
+
+test("first subscribe failure recovers without a reconnect callback and syncs before admission", async () => {
+ const clock = timers();
+ let subscribeCalls = 0;
+ let liveEvent;
+ let activeSubscriptions = 0;
+ let closed = 0;
+ let ready = 0;
+ const calls = [];
+ const relay = {
+ getSessionEpoch: () => 1,
+ getConnectionGeneration: () => 1,
+ subscribeLive: async (filter, onEvent, _onReady, _timeout, options) => {
+ assert.deepEqual(filter, {
+ kinds: [50182, 50180],
+ authors: [scope.owner],
+ limit: 0,
+ });
+ subscribeCalls++;
+ if (subscribeCalls === 1)
+ throw new Error("fixture first connection rejected");
+ activeSubscriptions++;
+ liveEvent = onEvent;
+ onEvent({ id: "during-sync", kind: 50182, created_at: 1 });
+ options.onState("eose");
+ return () => {
+ activeSubscriptions--;
+ closed++;
+ };
+ },
+ fetchEvents: async () => {
+ calls.push("history");
+ return [];
+ },
+ publishEvent: async () => {},
+ };
+ const ipc = async (command, args) => {
+ if (command === "observe_desktop_placement") {
+ calls.push(args.reconcile ? `projection:${args.events.length}` : "page");
+ return;
+ }
+ if (command === "receive_desktop_lifecycle") {
+ calls.push("admission");
+ return null;
+ }
+ throw new Error(command);
+ };
+ const errors = [];
+ const stop = ownLifecycleReceiver(
+ scope,
+ (error) => errors.push(error),
+ () => ready++,
+ {
+ ...clock,
+ waitForRateLimit: async () => {},
+ startReceiver: (receiverScope, active, onError, onReady, onClosed) =>
+ receiveLifecycle(
+ receiverScope,
+ active,
+ onError,
+ ipc,
+ relay,
+ onReady,
+ onClosed,
+ ),
+ },
+ );
+
+ await flushUntil(() => clock.pending.size === 1);
+ assert.equal(subscribeCalls, 1);
+ assert.deepEqual(errors, []);
+ assert.equal(clock.pending.values().next().value.delayMs, 1_000);
+
+ clock.fireNext();
+ await flushUntil(() => calls.includes("admission"));
+ assert.equal(subscribeCalls, 2);
+ assert.equal(activeSubscriptions, 1);
+ assert.equal(ready, 1);
+ assert.deepEqual(calls, [
+ "history",
+ "page",
+ "projection:0",
+ "projection:1",
+ "admission",
+ ]);
+
+ liveEvent({ id: "ordinary-live", kind: 50182, created_at: 2 });
+ await flushUntil(
+ () => calls.filter((call) => call === "admission").length === 2,
+ );
+ stop();
+ await flushUntil(() => activeSubscriptions === 0);
+ assert.equal(activeSubscriptions, 0);
+ assert.equal(closed, 1);
+});
+
+test("transient CLOSED before readiness retires and replaces the whole receiver", async () => {
+ const clock = timers();
+ let subscribeCalls = 0;
+ let closeCount = 0;
+ let ready = 0;
+ const relay = {
+ getSessionEpoch: () => 1,
+ getConnectionGeneration: () => 1,
+ subscribeLive: async (_filter, _onEvent, _onReady, _timeout, options) => {
+ subscribeCalls++;
+ if (subscribeCalls === 1)
+ options.onState("closed", {
+ classification: "retryable",
+ retryAfterMs: 0,
+ });
+ else options.onState("eose");
+ return () => closeCount++;
+ },
+ fetchEvents: async () => [],
+ publishEvent: async () => {},
+ };
+ const stop = ownLifecycleReceiver(
+ scope,
+ () => {},
+ () => ready++,
+ {
+ ...clock,
+ waitForRateLimit: async () => {},
+ startReceiver: (receiverScope, active, onError, onReady, onClosed) =>
+ receiveLifecycle(
+ receiverScope,
+ active,
+ onError,
+ async () => {},
+ relay,
+ onReady,
+ onClosed,
+ ),
+ },
+ );
+
+ await flushUntil(() => clock.pending.size === 1);
+ await flushUntil(() => closeCount === 1);
+ assert.equal(subscribeCalls, 1);
+ assert.equal(closeCount, 1);
+ clock.fireNext();
+ await flushUntil(() => ready === 1);
+ assert.equal(subscribeCalls, 2);
+ stop();
+ await flushUntil(() => closeCount === 2);
+});
+
+test("scope cancellation clears recovery timers and closes a late subscription", async () => {
+ const timerClock = timers();
+ let starts = 0;
+ const stopTimerOwner = ownLifecycleReceiver(
+ scope,
+ () => {},
+ () => {},
+ {
+ ...timerClock,
+ waitForRateLimit: async () => {},
+ startReceiver: async () => {
+ starts++;
+ throw new Error("transient");
+ },
+ },
+ );
+ await flushUntil(() => timerClock.pending.size === 1);
+ stopTimerOwner();
+ assert.equal(timerClock.pending.size, 0);
+ assert.equal(starts, 1);
+
+ let finishSubscribe;
+ let lateCloseCount = 0;
+ let historyCalls = 0;
+ const relay = {
+ getSessionEpoch: () => 1,
+ getConnectionGeneration: () => 1,
+ subscribeLive: () =>
+ new Promise((resolve) => {
+ finishSubscribe = () => resolve(() => lateCloseCount++);
+ }),
+ fetchEvents: async () => {
+ historyCalls++;
+ return [];
+ },
+ publishEvent: async () => {},
+ };
+ const stopLateOwner = ownLifecycleReceiver(
+ scope,
+ () => {},
+ () => {},
+ {
+ ...timers(),
+ waitForRateLimit: async () => {},
+ startReceiver: (receiverScope, active, onError, onReady, onClosed) =>
+ receiveLifecycle(
+ receiverScope,
+ active,
+ onError,
+ async () => {},
+ relay,
+ onReady,
+ onClosed,
+ ),
+ },
+ );
+ await flushUntil(() => typeof finishSubscribe === "function");
+ stopLateOwner();
+ finishSubscribe();
+ await flushUntil(() => lateCloseCount === 1);
+ assert.equal(historyCalls, 0, "cancelled receiver must not begin sync");
+});
+
+test("scope cancellation during sync fences reconciliation, admission, readiness, and errors", async () => {
+ let resolveHistory;
+ let deliver;
+ let closeCount = 0;
+ let ready = 0;
+ const errors = [];
+ const calls = [];
+ const relay = {
+ getSessionEpoch: () => 1,
+ getConnectionGeneration: () => 1,
+ subscribeLive: async (_filter, onEvent, _onReady, _timeout, options) => {
+ deliver = onEvent;
+ options.onState("eose");
+ return () => closeCount++;
+ },
+ fetchEvents: () =>
+ new Promise((resolve) => {
+ resolveHistory = resolve;
+ }),
+ publishEvent: async () => {},
+ };
+ const stop = ownLifecycleReceiver(
+ scope,
+ (error) => errors.push(error),
+ () => ready++,
+ {
+ ...timers(),
+ waitForRateLimit: async () => {},
+ startReceiver: (receiverScope, active, onError, onReady, onClosed) =>
+ receiveLifecycle(
+ receiverScope,
+ active,
+ onError,
+ async (command) => {
+ calls.push(command);
+ return null;
+ },
+ relay,
+ onReady,
+ onClosed,
+ ),
+ },
+ );
+ await flushUntil(() => resolveHistory !== undefined);
+ deliver({ id: "queued", kind: 50182, created_at: 1 });
+ stop();
+ resolveHistory([]);
+ await flushUntil(() => closeCount === 1);
+
+ assert.deepEqual(calls, []);
+ assert.deepEqual(errors, []);
+ assert.equal(ready, 0);
+});
+
+test("initializer recovery exhausts the bounded budget and preserves its safe terminal outcome", async () => {
+ const clock = timers();
+ let starts = 0;
+ const errors = [];
+ ownLifecycleReceiver(
+ scope,
+ (error) => errors.push(error),
+ () => {},
+ {
+ ...clock,
+ waitForRateLimit: async () => {},
+ startReceiver: async () => {
+ starts++;
+ throw new Error("raw private initializer detail");
+ },
+ },
+ );
+
+ for (
+ let attempt = 0;
+ attempt < RECEIVER_RECOVERY_DELAYS_MS.length;
+ attempt++
+ ) {
+ await flushUntil(() => clock.pending.size === 1);
+ assert.equal(
+ clock.pending.values().next().value.delayMs,
+ RECEIVER_RECOVERY_DELAYS_MS[attempt],
+ );
+ clock.fireNext();
+ }
+ await flushUntil(() => errors.length === 1);
+ assert.equal(starts, 4);
+ assert.equal(clock.pending.size, 0);
+ assert.equal(
+ errors[0],
+ "Desktop lifecycle receiver is unavailable (initialization failed).",
+ );
+ assert.doesNotMatch(errors[0], /private|detail/);
+});
+
+test("transient CLOSED recovery is bounded across successful receivers; terminal stays manual", async () => {
+ const clock = timers();
+ const closures = [];
+ let starts = 0;
+ let closes = 0;
+ const errors = [];
+ const stop = ownLifecycleReceiver(
+ scope,
+ (error) => errors.push(error),
+ () => {},
+ {
+ ...clock,
+ waitForRateLimit: async () => {},
+ startReceiver: async (_scope, _active, _onError, onReady, onClosed) => {
+ starts++;
+ onReady();
+ closures.push(onClosed);
+ return () => closes++;
+ },
+ },
+ );
+ await flushUntil(() => closures.length === 1);
+
+ for (
+ let attempt = 0;
+ attempt < RECEIVER_RECOVERY_DELAYS_MS.length;
+ attempt++
+ ) {
+ closures.at(-1)({ classification: "retryable", retryAfterMs: 0 });
+ assert.equal(
+ clock.pending.values().next().value.delayMs,
+ RECEIVER_RECOVERY_DELAYS_MS[attempt],
+ );
+ clock.fireNext();
+ await flushUntil(() => closures.length === attempt + 2);
+ }
+ closures.at(-1)({ classification: "retryable", retryAfterMs: 0 });
+ assert.equal(clock.pending.size, 0);
+ assert.equal(starts, 4);
+ assert.equal(closes, 4);
+ assert.equal(errors.length, 1);
+ assert.match(errors[0], /Retry the receiver/);
+ stop();
+
+ const terminalClock = timers();
+ let terminalClosed;
+ const terminalErrors = [];
+ ownLifecycleReceiver(
+ scope,
+ (error) => terminalErrors.push(error),
+ () => {},
+ {
+ ...terminalClock,
+ startReceiver: async (_scope, _active, _onError, _onReady, onClosed) => {
+ terminalClosed = onClosed;
+ return () => {};
+ },
+ },
+ );
+ await flushUntil(() => terminalClosed !== undefined);
+ terminalClosed({ classification: "terminal", retryAfterMs: 0 });
+ assert.equal(terminalClock.pending.size, 0);
+ assert.equal(terminalErrors.length, 1);
+});
+
+test("rate-limited recovery honors both the delay and shared gate, and cancellation fences it", async () => {
+ const clock = timers();
+ let releaseGate;
+ const gate = new Promise((resolve) => {
+ releaseGate = resolve;
+ });
+ const closures = [];
+ let starts = 0;
+ const stop = ownLifecycleReceiver(
+ scope,
+ () => {},
+ () => {},
+ {
+ ...clock,
+ waitForRateLimit: () => gate,
+ startReceiver: async (_scope, _active, _onError, _onReady, onClosed) => {
+ starts++;
+ closures.push(onClosed);
+ return () => {};
+ },
+ },
+ );
+ await flushUntil(() => closures.length === 1);
+ closures[0]({ classification: "rate-limited", retryAfterMs: 8_000 });
+ assert.equal(clock.pending.values().next().value.delayMs, 8_000);
+ clock.fireNext();
+ await Promise.resolve();
+ assert.equal(starts, 1, "fresh subscription must wait for the active gate");
+ stop();
+ releaseGate();
+ await Promise.resolve();
+ await Promise.resolve();
+ assert.equal(starts, 1, "scope cancellation must fence the late gate result");
+});
diff --git a/desktop/src/features/agents/desktopLifecycleReceiver.ts b/desktop/src/features/agents/desktopLifecycleReceiver.ts
new file mode 100644
index 00000000000..12a2a81ff29
--- /dev/null
+++ b/desktop/src/features/agents/desktopLifecycleReceiver.ts
@@ -0,0 +1,142 @@
+import type { LiveSubscriptionClosedRecovery } from "@/shared/api/relayClientShared";
+import { waitForRateLimit } from "@/shared/api/relayRateLimitGate";
+import type { DesktopScope } from "./desktopList";
+import { receiveLifecycle } from "./desktopLifecycle";
+import { receiverErrorMessage } from "./desktopLifecycleDiagnostics";
+
+export const RECEIVER_RECOVERY_DELAYS_MS = [1_000, 2_000, 4_000] as const;
+
+const CLOSED_MESSAGE =
+ "Desktop lifecycle receiver subscription closed. Retry the receiver to accept new requests.";
+
+type StartReceiver = (
+ scope: DesktopScope,
+ active: () => boolean,
+ onError: (message: string) => void,
+ onReady: () => void,
+ onClosed: (recovery: LiveSubscriptionClosedRecovery) => void,
+) => Promise<() => void>;
+
+type ReceiverOwnerDependencies = {
+ startReceiver?: StartReceiver;
+ waitForRateLimit?: () => Promise;
+ setTimer?: (callback: () => void, delayMs: number) => number;
+ clearTimer?: (timer: number) => void;
+};
+
+/**
+ * Owns one lifecycle receiver scope. Recovery always creates a fresh live-only
+ * subscription and lets receiveLifecycle repeat projection sync before it
+ * admits events. The attempt budget belongs to this owner and is intentionally
+ * not reset by a successful EOSE/sync followed by another CLOSED.
+ */
+export function ownLifecycleReceiver(
+ scope: DesktopScope,
+ onError: (message: string) => void,
+ onReady: () => void,
+ dependencies: ReceiverOwnerDependencies = {},
+) {
+ const startReceiver: StartReceiver =
+ dependencies.startReceiver ??
+ ((receiverScope, active, reportError, ready, closed) =>
+ receiveLifecycle(
+ receiverScope,
+ active,
+ reportError,
+ undefined,
+ undefined,
+ ready,
+ closed,
+ ));
+ const waitForGate = dependencies.waitForRateLimit ?? waitForRateLimit;
+ const setTimer =
+ dependencies.setTimer ??
+ ((callback, delayMs) => window.setTimeout(callback, delayMs));
+ const clearTimer =
+ dependencies.clearTimer ?? ((timer) => window.clearTimeout(timer));
+
+ let stopped = false;
+ let generation = 0;
+ let recoveryAttempt = 0;
+ let timer: number | undefined;
+ let closeCurrent: (() => void) | undefined;
+
+ const current = (token: number) => !stopped && generation === token;
+
+ const retireCurrent = () => {
+ const close = closeCurrent;
+ closeCurrent = undefined;
+ close?.();
+ };
+
+ const recover = (
+ token: number,
+ terminalMessage: string,
+ recovery: LiveSubscriptionClosedRecovery,
+ ) => {
+ if (!current(token)) return;
+ generation++;
+ retireCurrent();
+
+ if (
+ recovery.classification === "terminal" ||
+ recoveryAttempt >= RECEIVER_RECOVERY_DELAYS_MS.length
+ ) {
+ onError(terminalMessage);
+ return;
+ }
+
+ const delayMs = Math.max(
+ RECEIVER_RECOVERY_DELAYS_MS[recoveryAttempt],
+ recovery.retryAfterMs,
+ );
+ recoveryAttempt++;
+ const waitingGeneration = generation;
+ timer = setTimer(() => {
+ timer = undefined;
+ if (stopped || generation !== waitingGeneration) return;
+ void waitForGate().then(() => {
+ if (!stopped && generation === waitingGeneration) start();
+ });
+ }, delayMs);
+ };
+
+ const start = () => {
+ const token = ++generation;
+ void startReceiver(
+ scope,
+ () => current(token),
+ (message) => {
+ if (current(token)) onError(message);
+ },
+ () => {
+ if (current(token)) onReady();
+ },
+ (recovery) => recover(token, CLOSED_MESSAGE, recovery),
+ )
+ .then((close) => {
+ if (current(token)) closeCurrent = close;
+ else close();
+ })
+ .catch((error) => {
+ if (current(token))
+ recover(token, receiverErrorMessage(error), {
+ classification: "retryable",
+ retryAfterMs: 0,
+ });
+ });
+ };
+
+ start();
+
+ return () => {
+ if (stopped) return;
+ stopped = true;
+ generation++;
+ if (timer !== undefined) {
+ clearTimer(timer);
+ timer = undefined;
+ }
+ retireCurrent();
+ };
+}
diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs
index 35e52f7aab0..17aff55e75a 100644
--- a/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs
+++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs
@@ -136,7 +136,7 @@ test("mounted Start exposes unavailable provisioning and exact retry; Restart re
}
});
-test("receiver failure is a scope-owned notification, not pre-shell layout", async () => {
+test("terminal receiver failure is a scope-owned notification, not pre-shell layout", async () => {
const originalRaf = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0);
const dom = new JSDOM("
", {
@@ -157,7 +157,6 @@ test("receiver failure is a scope-owned notification, not pre-shell layout", asy
let closed = 0;
let rejectLate;
let delayed = false;
- let storageAvailable = false;
let subscribed = 0;
relayClient.fetchEvents = async () => {
if (delayed)
@@ -181,10 +180,7 @@ test("receiver failure is a scope-owned notification, not pre-shell layout", asy
};
};
window.__TAURI_INTERNALS__ = {
- invoke: async () => {
- if (storageAvailable) return;
- throw new Error("fixture: storage unavailable");
- },
+ invoke: async () => {},
};
const root = createRoot(document.getElementById("root"));
const scope = { owner: "owner", community: "wss://one.example" };
@@ -201,14 +197,18 @@ test("receiver failure is a scope-owned notification, not pre-shell layout", asy
0,
"startup must not render in-flow failure UI",
);
+ assert.equal(warnings().length, 0);
+ await React.act(async () =>
+ readiness("closed", { classification: "terminal", retryAfterMs: 0 }),
+ );
assert.equal(warnings().length, 1);
assert.equal(
warnings()[0].title,
- "Desktop lifecycle receiver is unavailable (projection: request failed).",
+ "Desktop lifecycle receiver subscription closed. Retry the receiver to accept new requests.",
);
assert.equal(warnings()[0].duration, Infinity);
assert.equal(warnings()[0].closeButton, true);
- readiness("closed");
+ readiness("closed", { classification: "terminal", retryAfterMs: 0 });
assert.equal(
warnings().length,
1,
@@ -216,7 +216,6 @@ test("receiver failure is a scope-owned notification, not pre-shell layout", asy
);
const retryAction = warnings()[0].action;
assert.equal(retryAction.label, "Retry receiver");
- storageAvailable = true;
await React.act(async () => retryAction.onClick());
assert.equal(subscribed, 2, "explicit recovery starts a new live receiver");
assert.equal(
@@ -230,7 +229,7 @@ test("receiver failure is a scope-owned notification, not pre-shell layout", asy
),
);
assert.equal(warnings().length, 0, "leaving the scope removes its warning");
- readiness("closed");
+ readiness("closed", { classification: "terminal", retryAfterMs: 0 });
assert.equal(
warnings().length,
0,
diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx
index 0b6b63d67be..8212eb7258b 100644
--- a/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx
+++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx
@@ -3,13 +3,9 @@ import { toast } from "sonner";
import { Button } from "@/shared/ui/button";
import type { RelayEvent } from "@/shared/api/types";
import type { DesktopRow, DesktopScope } from "../desktopList";
-import {
- lifecycleClient,
- receiveLifecycle,
- type LifecycleOutcome,
-} from "../desktopLifecycle";
+import { lifecycleClient, type LifecycleOutcome } from "../desktopLifecycle";
+import { ownLifecycleReceiver } from "../desktopLifecycleReceiver";
import { useRelayAgentsQuery } from "../hooks";
-import { receiverErrorMessage } from "../desktopLifecycleDiagnostics";
export function DesktopLifecycleReceiver({
scope,
@@ -21,7 +17,7 @@ export function DesktopLifecycleReceiver({
useEffect(() => {
if (!owner || !community) return;
let active = true;
- let close: (() => void) | undefined;
+ let stop = () => {};
let notification: string | number | undefined;
const reportError = (message: string) => {
if (!active) return;
@@ -37,32 +33,18 @@ export function DesktopLifecycleReceiver({
onClick: () => {
if (!active) return;
active = false;
- close?.();
+ stop();
retry(attempt + 1);
},
},
});
};
- void receiveLifecycle(
- { owner, community },
- () => active,
- reportError,
- undefined,
- undefined,
- () => {
- if (active && notification !== undefined) toast.dismiss(notification);
- },
- )
- .then((fn) => {
- if (active) close = fn;
- else fn();
- })
- .catch((error) => {
- reportError(receiverErrorMessage(error));
- });
+ stop = ownLifecycleReceiver({ owner, community }, reportError, () => {
+ if (active && notification !== undefined) toast.dismiss(notification);
+ });
return () => {
active = false;
- close?.();
+ stop();
if (notification !== undefined) toast.dismiss(notification);
};
}, [owner, community, attempt]);
diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts
index a7c0bded02d..5ba009e6476 100644
--- a/desktop/src/shared/api/relayClientSession.ts
+++ b/desktop/src/shared/api/relayClientSession.ts
@@ -164,6 +164,12 @@ export class RelayClient {
sub.reject(error);
} else {
clearClosedRetry(sub);
+ sub.resolveReady?.("closed");
+ sub.onState?.("closed", {
+ classification: "terminal",
+ retryAfterMs: 0,
+ });
+ sub.resolveReady = undefined;
}
this.subscriptions.delete(subId);
}
@@ -635,19 +641,23 @@ export class RelayClient {
resolveReady("timeout");
}, readinessTimeoutMs);
- this.subscriptions.set(subId, {
+ const subscription: Extract = {
mode: "live",
filter,
onEvent,
resolveReady,
onState: options.onState,
closedRecovery: options.closedRecovery ?? "shared",
- });
+ };
+ this.subscriptions.set(subId, subscription);
try {
await this.sendRawWithReconnectRetry(
["REQ", subId, filter],
"Failed to restore relay subscription.",
+ () =>
+ subscription.closedRecovery !== "explicit" ||
+ this.subscriptions.get(subId) === subscription,
);
} catch (error) {
window.clearTimeout(fallbackTimeout);
@@ -709,6 +719,7 @@ export class RelayClient {
private async sendRawWithReconnectRetry(
payload: unknown[],
fallbackMessage: string,
+ retryStillOwned: () => boolean = () => true,
) {
try {
await this.sendRaw(payload);
@@ -717,8 +728,13 @@ export class RelayClient {
error,
fallbackMessage,
);
+ // resetConnection may retire an explicit subscription synchronously.
+ // Never put its now-ownerless REQ onto the replacement connection;
+ // shared subscriptions retain their existing reconnect retry behavior.
+ if (!retryStillOwned()) throw normalizedError;
try {
await this.ensureConnected();
+ if (!retryStillOwned()) throw normalizedError;
await this.sendRaw(payload);
} catch (retryError) {
throw this.recoverFromSocketFailure(
@@ -1077,7 +1093,10 @@ export class RelayClient {
continue;
}
subscription.resolveReady?.("closed");
- subscription.onState?.("closed");
+ subscription.onState?.("closed", {
+ classification: options?.reconnect === false ? "terminal" : "retryable",
+ retryAfterMs: 0,
+ });
subscription.resolveReady = undefined;
clearClosedRetry(subscription);
if (subscription.closedRecovery === "explicit") {
diff --git a/desktop/src/shared/api/relayClientShared.ts b/desktop/src/shared/api/relayClientShared.ts
index 925c74b573d..09064dc6276 100644
--- a/desktop/src/shared/api/relayClientShared.ts
+++ b/desktop/src/shared/api/relayClientShared.ts
@@ -1,4 +1,5 @@
import type { RelayEvent } from "@/shared/api/types";
+import type { RelayClosedClass } from "@/shared/api/relayClosedPolicy";
/**
* Observable connection state for the relay singleton.
@@ -70,16 +71,26 @@ type FirstEventSubscription = {
export type LiveSubscriptionReadiness = "eose" | "closed" | "timeout";
+export type LiveSubscriptionClosedRecovery = {
+ classification: RelayClosedClass;
+ /** Minimum delay before the owner creates a fresh subscription. */
+ retryAfterMs: number;
+};
+
/**
* Optional lifecycle policy for a live subscription.
*
* Most subscriptions keep the shared reconnect/CLOSED recovery behavior. A
* command receiver can instead request explicit recovery: every CLOSED retires
- * that subscription and `onState` remains observable after initial EOSE so the
- * owning UI can offer a deliberate fresh subscription.
+ * that subscription and `onState` remains observable after initial EOSE. The
+ * owner receives only a safe recovery class/delay and decides whether to create
+ * a fresh subscription or require deliberate retry.
*/
export type LiveSubscriptionOptions = {
- onState?: (state: LiveSubscriptionReadiness) => void;
+ onState?: (
+ state: LiveSubscriptionReadiness,
+ closed?: LiveSubscriptionClosedRecovery,
+ ) => void;
closedRecovery?: "shared" | "explicit";
};
@@ -88,7 +99,10 @@ type LiveSubscription = {
filter: RelaySubscriptionFilter;
onEvent: (event: RelayEvent) => void;
resolveReady?: (readiness: LiveSubscriptionReadiness) => void;
- onState?: (state: LiveSubscriptionReadiness) => void;
+ onState?: (
+ state: LiveSubscriptionReadiness,
+ closed?: LiveSubscriptionClosedRecovery,
+ ) => void;
closedRecovery: "shared" | "explicit";
lastSeenCreatedAt?: number;
/**
diff --git a/desktop/src/shared/api/relayClosedRecovery.ts b/desktop/src/shared/api/relayClosedRecovery.ts
index 8f1a68287d2..d249bf2406d 100644
--- a/desktop/src/shared/api/relayClosedRecovery.ts
+++ b/desktop/src/shared/api/relayClosedRecovery.ts
@@ -123,10 +123,6 @@ function recoverLiveSubscriptionFromClosed({
message: string;
sendReq: (subId: string, filter: RelaySubscriptionFilter) => Promise;
}) {
- subscription.resolveReady?.("closed");
- subscription.onState?.("closed");
- subscription.resolveReady = undefined;
-
const closedClass = classifyRelayClosed(message);
if (closedClass === "rate-limited") {
@@ -134,6 +130,13 @@ function recoverLiveSubscriptionFromClosed({
activateRateLimit(hintSeconds);
}
+ subscription.resolveReady?.("closed");
+ subscription.onState?.("closed", {
+ classification: closedClass,
+ retryAfterMs: closedClass === "rate-limited" ? rateLimitRemainingMs() : 0,
+ });
+ subscription.resolveReady = undefined;
+
if (subscription.closedRecovery === "explicit") {
// Command receivers must not survive CLOSED into shared re-subscription.
// Their owner presents an explicit fresh-receiver action instead.
diff --git a/desktop/src/shared/api/relayLiveSubscriptionState.test.mjs b/desktop/src/shared/api/relayLiveSubscriptionState.test.mjs
index 587bfae1f28..a52d408bec3 100644
--- a/desktop/src/shared/api/relayLiveSubscriptionState.test.mjs
+++ b/desktop/src/shared/api/relayLiveSubscriptionState.test.mjs
@@ -5,6 +5,8 @@ let fakeNow = 0;
let nextTimerId = 1;
const pendingTimers = new Map();
const sentFrames = [];
+const sendAttempts = [];
+let failNextSend = false;
globalThis.window = {
setTimeout: (fn, ms) => {
@@ -15,7 +17,14 @@ globalThis.window = {
clearTimeout: (id) => pendingTimers.delete(id),
__TAURI_INTERNALS__: {
invoke: async (command, args) => {
- if (command === "plugin:websocket|send") sentFrames.push(args);
+ if (command === "plugin:websocket|send") {
+ sendAttempts.push(args);
+ if (failNextSend) {
+ failNextSend = false;
+ throw new Error("fixture first send failed");
+ }
+ sentFrames.push(args);
+ }
},
},
};
@@ -30,6 +39,8 @@ function resetHarness() {
nextTimerId = 1;
pendingTimers.clear();
sentFrames.length = 0;
+ sendAttempts.length = 0;
+ failNextSend = false;
resetRateLimitGate();
}
@@ -117,17 +128,21 @@ test("persistent state reports timeout, late EOSE, then CLOSED through RelayClie
await close();
});
-for (const [label, message] of [
- ["terminal", "restricted: access revoked"],
- ["retryable", "error: storage temporarily unavailable"],
+for (const [label, message, classification] of [
+ ["terminal", "restricted: access revoked", "terminal"],
+ ["retryable", "error: storage temporarily unavailable", "retryable"],
]) {
test(`explicit recovery retires an EOSE-ready ${label} CLOSED without re-REQ`, async () => {
resetHarness();
const client = connectedClient();
const states = [];
+ const recoveries = [];
const { opened, subId } = await openLive(client, {
closedRecovery: "explicit",
- onState: (state) => states.push(state),
+ onState: (state, recovery) => {
+ states.push(state);
+ if (recovery) recoveries.push(recovery);
+ },
});
await deliver(client, ["EOSE", subId]);
const close = await opened;
@@ -137,6 +152,7 @@ for (const [label, message] of [
await Promise.resolve();
assert.deepEqual(states, ["eose", "closed"]);
+ assert.deepEqual(recoveries, [{ classification, retryAfterMs: 0 }]);
assert.equal(client.subscriptions.has(subId), false);
assert.equal(sentProtocolFrames("REQ").length, 1);
await close();
@@ -173,6 +189,112 @@ test("connection reset reports CLOSED and retires only explicit-recovery subscri
client.disconnect();
});
+test("disconnect reports terminal CLOSED before retiring an explicit subscription", async () => {
+ resetHarness();
+ const client = connectedClient();
+ const states = [];
+ const recoveries = [];
+ const explicit = await openLive(client, {
+ closedRecovery: "explicit",
+ onState: (state, recovery) => {
+ states.push(state);
+ if (recovery) recoveries.push(recovery);
+ },
+ });
+ await deliver(client, ["EOSE", explicit.subId]);
+ await explicit.opened;
+
+ client.disconnect();
+
+ assert.deepEqual(states, ["eose", "closed"]);
+ assert.deepEqual(recoveries, [
+ { classification: "terminal", retryAfterMs: 0 },
+ ]);
+ assert.equal(client.subscriptions.has(explicit.subId), false);
+});
+
+test("explicit rate-limited CLOSED exposes only classified recovery and gate delay", async () => {
+ resetHarness();
+ const client = connectedClient();
+ let recovery;
+ const explicit = await openLive(client, {
+ closedRecovery: "explicit",
+ onState: (state, detail) => {
+ if (state === "closed") recovery = detail;
+ },
+ });
+ await deliver(client, ["EOSE", explicit.subId]);
+ await explicit.opened;
+
+ await deliver(client, [
+ "CLOSED",
+ explicit.subId,
+ "rate-limited: private relay detail; retry in 4s",
+ ]);
+
+ assert.deepEqual(recovery, {
+ classification: "rate-limited",
+ retryAfterMs: 4_000,
+ });
+ assert.equal(
+ JSON.stringify(recovery).includes("private relay detail"),
+ false,
+ "raw relay payload must not cross the recovery contract",
+ );
+});
+
+test("explicit retirement during first send failure prevents a fresh ownerless REQ", async () => {
+ resetHarness();
+ const client = connectedClient();
+ let ensureCalls = 0;
+ client.ensureConnected = async () => {
+ ensureCalls++;
+ if (ensureCalls > 1) client.wsId = 8;
+ return client.connectionGeneration;
+ };
+ failNextSend = true;
+
+ await assert.rejects(
+ client.subscribeLive(
+ { kinds: [50182], authors: ["owner"], limit: 0 },
+ () => {},
+ undefined,
+ 5_000,
+ { closedRecovery: "explicit" },
+ ),
+ /fixture first send failed/,
+ );
+
+ assert.equal(
+ ensureCalls,
+ 1,
+ "retired subscription must not reconnect to retry",
+ );
+ assert.equal(sendAttempts.length, 1);
+ assert.equal(sentProtocolFrames("REQ").length, 0);
+ assert.equal(client.subscriptions.size, 0);
+});
+
+test("ordinary subscription still retries its first failed send", async () => {
+ resetHarness();
+ const client = connectedClient();
+ let ensureCalls = 0;
+ client.ensureConnected = async () => {
+ ensureCalls++;
+ if (ensureCalls > 1) client.wsId = 8;
+ return client.connectionGeneration;
+ };
+ failNextSend = true;
+
+ const close = await client.subscribeLive({ kinds: [9], limit: 0 }, () => {});
+
+ assert.equal(ensureCalls, 2);
+ assert.equal(sendAttempts.length, 2);
+ assert.equal(sentProtocolFrames("REQ").length, 1);
+ await close();
+ client.disconnect();
+});
+
test("default live subscribers retain shared retry after retryable CLOSED", async () => {
resetHarness();
const client = connectedClient();
diff --git a/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts b/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts
index 97d6b69cb1f..5a49175d085 100644
--- a/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts
+++ b/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts
@@ -116,10 +116,11 @@ test.describe("top chrome macOS traffic-light clearance under text zoom", () =>
});
await page.goto("/");
// A failed global receiver must remain visible without entering the shell's
- // layout flow. This also forces the error to settle before measuring chrome.
+ // layout flow. Wait through its bounded 1s/2s/4s recovery budget before
+ // measuring chrome; an intermediate transient failure need not notify.
await expect(
page.getByText(/Desktop lifecycle receiver is unavailable/),
- ).toBeVisible();
+ ).toBeVisible({ timeout: 15_000 });
// Lock the native and webview placements together: removing this explicit
// Tauri inset or shifting the nav row regresses the macOS chrome alignment.
From ef58314dd5da1fcbbcde6ebe757544ea42a83435 Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Sat, 5 Sep 2026 00:01:45 -0400
Subject: [PATCH 29/51] fix(desktop): do not retry terminal receiver
initialization
Signed-off-by: Logan Johnson
---
desktop/src/features/agents/AGENTS.md | 6 ++-
.../agents/desktopLifecycleDiagnostics.ts | 9 ++++
.../agents/desktopLifecycleReceiver.test.mjs | 44 +++++++++++++++++++
.../agents/desktopLifecycleReceiver.ts | 10 ++++-
4 files changed, 65 insertions(+), 4 deletions(-)
diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md
index e48401b4765..a6b0855394d 100644
--- a/desktop/src/features/agents/AGENTS.md
+++ b/desktop/src/features/agents/AGENTS.md
@@ -318,8 +318,10 @@ failures and transient CLOSED states recover through a bounded receiver-owner
budget; each attempt uses a fresh live-only subscription and repeats
projection-only sync before admission. Terminal closure or exhausted recovery
stays in the scope-owned notification, whose deliberate retry resets the receiver
-budget. Recovery must discard queued callbacks from the retired receiver, not
-retry an operation, and must respect the relay rate-limit gate.
+budget. A known latched-terminal relay session also reports immediately during
+initialization without consuming that budget; unknown and transient failures
+remain bounded retries. Recovery must discard queued callbacks from the retired
+receiver, not retry an operation, and must respect the relay rate-limit gate.
A readiness timeout is unconfirmed delivery, not a failed initialization; late
EOSE clears that warning after successful projection. An explicit operation retry republishes the exact request;
the relay redelivers stored Stop duplicates without repeating relay side effects.
diff --git a/desktop/src/features/agents/desktopLifecycleDiagnostics.ts b/desktop/src/features/agents/desktopLifecycleDiagnostics.ts
index aebe01c41a9..cee8e60769e 100644
--- a/desktop/src/features/agents/desktopLifecycleDiagnostics.ts
+++ b/desktop/src/features/agents/desktopLifecycleDiagnostics.ts
@@ -6,9 +6,17 @@ type Stage =
| "projection"
| "reconciliation";
+export type LifecycleReceiverFailureClassification = "retryable" | "terminal";
+
export class LifecycleReceiverError extends Error {
+ readonly recoveryClassification: LifecycleReceiverFailureClassification;
+
constructor(stage: Stage, error: unknown) {
const value = error instanceof Error ? error.message : error;
+ const recoveryClassification =
+ value === "Relay session is terminal; cannot reconnect."
+ ? "terminal"
+ : "retryable";
const reason =
value === "closed"
? "subscription closed"
@@ -20,6 +28,7 @@ export class LifecycleReceiverError extends Error {
? "history timed out"
: "request failed";
super(`Desktop lifecycle receiver is unavailable (${stage}: ${reason}).`);
+ this.recoveryClassification = recoveryClassification;
}
}
diff --git a/desktop/src/features/agents/desktopLifecycleReceiver.test.mjs b/desktop/src/features/agents/desktopLifecycleReceiver.test.mjs
index 70b96890052..6948a55ca65 100644
--- a/desktop/src/features/agents/desktopLifecycleReceiver.test.mjs
+++ b/desktop/src/features/agents/desktopLifecycleReceiver.test.mjs
@@ -133,6 +133,50 @@ test("first subscribe failure recovers without a reconnect callback and syncs be
assert.equal(closed, 1);
});
+test("terminal relay-session initialization failure does not consume the recovery budget", async () => {
+ const clock = timers();
+ let subscribeCalls = 0;
+ const relay = {
+ getSessionEpoch: () => 1,
+ getConnectionGeneration: () => 1,
+ subscribeLive: async () => {
+ subscribeCalls++;
+ throw new Error("Relay session is terminal; cannot reconnect.");
+ },
+ fetchEvents: async () => assert.fail("terminal session must not sync"),
+ publishEvent: async () => {},
+ };
+ const errors = [];
+ ownLifecycleReceiver(
+ scope,
+ (error) => errors.push(error),
+ () => {},
+ {
+ ...clock,
+ waitForRateLimit: async () => {},
+ startReceiver: (receiverScope, active, onError, onReady, onClosed) =>
+ receiveLifecycle(
+ receiverScope,
+ active,
+ onError,
+ async () =>
+ assert.fail("terminal session must not invoke native IPC"),
+ relay,
+ onReady,
+ onClosed,
+ ),
+ },
+ );
+
+ await flushUntil(() => errors.length === 1);
+ assert.equal(subscribeCalls, 1);
+ assert.equal(clock.pending.size, 0);
+ assert.equal(
+ errors[0],
+ "Desktop lifecycle receiver is unavailable (subscription: relay session requires reconnection).",
+ );
+});
+
test("transient CLOSED before readiness retires and replaces the whole receiver", async () => {
const clock = timers();
let subscribeCalls = 0;
diff --git a/desktop/src/features/agents/desktopLifecycleReceiver.ts b/desktop/src/features/agents/desktopLifecycleReceiver.ts
index 12a2a81ff29..62417287e91 100644
--- a/desktop/src/features/agents/desktopLifecycleReceiver.ts
+++ b/desktop/src/features/agents/desktopLifecycleReceiver.ts
@@ -2,7 +2,10 @@ import type { LiveSubscriptionClosedRecovery } from "@/shared/api/relayClientSha
import { waitForRateLimit } from "@/shared/api/relayRateLimitGate";
import type { DesktopScope } from "./desktopList";
import { receiveLifecycle } from "./desktopLifecycle";
-import { receiverErrorMessage } from "./desktopLifecycleDiagnostics";
+import {
+ LifecycleReceiverError,
+ receiverErrorMessage,
+} from "./desktopLifecycleDiagnostics";
export const RECEIVER_RECOVERY_DELAYS_MS = [1_000, 2_000, 4_000] as const;
@@ -121,7 +124,10 @@ export function ownLifecycleReceiver(
.catch((error) => {
if (current(token))
recover(token, receiverErrorMessage(error), {
- classification: "retryable",
+ classification:
+ error instanceof LifecycleReceiverError
+ ? error.recoveryClassification
+ : "retryable",
retryAfterMs: 0,
});
});
From 546f5ec16aea9853aa09ab58a10a6fa620a6db65 Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Sat, 5 Sep 2026 00:20:33 -0400
Subject: [PATCH 30/51] fix(desktop): preserve runtime community authority and
refuse lossy receipts
Signed-off-by: Logan Johnson
---
crates/buzz-core/src/relay.rs | 19 +-
desktop/src-tauri/src/commands/agents.rs | 4 +-
.../src/managed_agents/remote_stop.rs | 18 +-
.../src-tauri/src/managed_agents/restore.rs | 12 +-
.../src-tauri/src/managed_agents/runtime.rs | 38 +-
.../managed_agents/runtime/authority_tests.rs | 364 ++++++++++++++++++
.../src/managed_agents/runtime/process.rs | 108 +++++-
.../src/managed_agents/runtime/spawn_key.rs | 17 +
.../src/managed_agents/runtime/stop.rs | 185 ++++++++-
.../managed_agents/runtime/test_fixtures.rs | 125 +++++-
.../src/managed_agents/runtime/tests.rs | 73 +---
.../src/managed_agents/runtime_commands.rs | 251 ++++++++++--
.../src/managed_agents/runtime_types.rs | 160 +++++++-
.../src/managed_agents/session_policy.rs | 4 +-
.../src-tauri/src/managed_agents/storage.rs | 14 +-
desktop/src-tauri/src/relay/scope.rs | 2 +-
.../managedAgentReconciliationPlan.test.mjs | 14 +-
...managedAgentRuntimeReconciliation.test.mjs | 13 +
.../agents/managedAgentRuntimeStatus.test.mjs | 54 ++-
.../agents/managedAgentRuntimeStatus.ts | 54 ++-
.../src/protectedFeatures/bestie/useBestie.ts | 4 +-
21 files changed, 1323 insertions(+), 210 deletions(-)
create mode 100644 desktop/src-tauri/src/managed_agents/runtime/authority_tests.rs
diff --git a/crates/buzz-core/src/relay.rs b/crates/buzz-core/src/relay.rs
index 77c74a069ad..ba14dc87db0 100644
--- a/crates/buzz-core/src/relay.rs
+++ b/crates/buzz-core/src/relay.rs
@@ -1,4 +1,4 @@
-//! Canonical relay identities shared by runtime components.
+//! Legacy canonical relay identities shared by compatibility consumers.
use thiserror::Error;
use url::{Host, Url};
@@ -23,17 +23,16 @@ pub enum NormalizeRelayUrlError {
MissingHost,
}
-/// Canonicalize a WebSocket relay URL for use as a runtime identity key.
+/// Canonicalize a WebSocket relay URL for legacy equivalence consumers.
///
-/// This is the sole normalizer for `(agent, relay)` process identity. It keeps
-/// the WebSocket scheme, lowercases DNS hosts, folds all loopback spellings to
-/// `127.0.0.1`, removes default ports and a root slash, and preserves non-root
-/// paths and queries. It deliberately is **not** the NIP-42 AUTH comparison
-/// helper in `buzz-auth`: AUTH validation is a security boundary with narrower
-/// equivalence rules and must not be widened by runtime-key canonicalization.
+/// Bestie scope and pollen/profile migration retain this historical behavior:
+/// keep the WebSocket scheme, lowercase DNS hosts, fold all loopback spellings
+/// to `127.0.0.1`, remove default ports and trailing slashes, and preserve
+/// queries. Managed-agent process identity intentionally uses a scoped
+/// host-preserving normalizer because relay hosts are tenant authorities.
///
-/// Connection code may retain the configured URL; this canonical form is for
-/// identity, receipts, status and deduplication.
+/// This deliberately is **not** the NIP-42 AUTH comparison helper in
+/// `buzz-auth`; changing either equivalence contract requires a separate review.
pub fn normalize_relay_url(raw: &str) -> Result {
let mut url = Url::parse(raw.trim())
.map_err(|error| NormalizeRelayUrlError::InvalidUrl(error.to_string()))?;
diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs
index df092d7dc44..af1cae86f8c 100644
--- a/desktop/src-tauri/src/commands/agents.rs
+++ b/desktop/src-tauri/src/commands/agents.rs
@@ -176,8 +176,8 @@ async fn start_local_agent_with_preflight(
replay_floor_unix: Option,
) -> Result {
let launch_owner = workspace_owner_hex(state)?;
- // Runtime keys fold loopback aliases for process bookkeeping, not tenant
- // identity. Preserve the workspace authority across the preflight await.
+ // Runtime keys preserve the workspace host authority. Bind that same
+ // authority across the preflight await so the eventual spawn cannot move.
let launch_relay = crate::relay::bind_expected_relay_scope(
expected_relay_url,
relay_ws_url_with_override(state),
diff --git a/desktop/src-tauri/src/managed_agents/remote_stop.rs b/desktop/src-tauri/src/managed_agents/remote_stop.rs
index e6c93750389..81a21e353fd 100644
--- a/desktop/src-tauri/src/managed_agents/remote_stop.rs
+++ b/desktop/src-tauri/src/managed_agents/remote_stop.rs
@@ -267,7 +267,10 @@ mod tests {
let community = "ws://localhost:3037";
let agent = Keys::generate().public_key().to_hex();
let key = ManagedAgentRuntimeKey::new(&agent, community).unwrap();
- assert_ne!(key.relay_url, community);
+ assert_eq!(key.relay_url, community);
+ let numeric_community = "ws://127.0.0.1:3037";
+ let numeric_key = ManagedAgentRuntimeKey::new(&agent, numeric_community).unwrap();
+ assert_ne!(key, numeric_key);
let scope = super::super::retention::RetentionScope {
db_path: scoped_retention_db_path(&root.join("agents"), community, &owner),
relay_url: community.into(),
@@ -293,9 +296,16 @@ mod tests {
)
.unwrap();
assert!(check_launch(app.handle(), &key, community, Some(&owner), None).is_err());
- // The numeric community is a different authority, even though the
- // process bookkeeping key historically folds the two spellings.
- assert!(check_launch(app.handle(), &key, &key.relay_url, Some(&owner), None).is_ok());
+ // The numeric community is a different authority with its own runtime
+ // identity and fence database.
+ assert!(check_launch(
+ app.handle(),
+ &numeric_key,
+ numeric_community,
+ Some(&owner),
+ None
+ )
+ .is_ok());
let resume = capture_resume(app.handle(), &key, community, &owner).unwrap();
assert!(check_launch(app.handle(), &key, community, Some(&owner), Some(&resume)).is_ok());
let newer = request(&keys, &target, 101);
diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs
index 5e282c239d4..9334b0dc0dd 100644
--- a/desktop/src-tauri/src/managed_agents/restore.rs
+++ b/desktop/src-tauri/src/managed_agents/restore.rs
@@ -397,12 +397,12 @@ pub async fn restore_managed_agents_on_launch(
continue;
};
let now = util::now_iso();
- let receipt = super::ManagedAgentRuntimeReceipt {
- key: key.clone(),
- pid: process.child.id(),
- desktop_instance_id: super::current_instance_id(app),
- started_at: now.clone(),
- };
+ let receipt = super::ManagedAgentRuntimeReceipt::new(
+ key.clone(),
+ process.child.id(),
+ super::current_instance_id(app),
+ now.clone(),
+ );
if let Err(error) = super::write_agent_runtime_receipt(app, &receipt) {
let _ = super::terminate_process(process.child.id());
let _ = process.child.wait();
diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs
index 8efe5826291..adabff1ce16 100644
--- a/desktop/src-tauri/src/managed_agents/runtime.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime.rs
@@ -41,11 +41,13 @@ mod process;
#[cfg(test)]
use process::{
buzz_marker_entry, name_matches_interpreter, name_matches_known_binary,
- terminate_runtime_receipt_with, valid_agent_runtime_receipt_with,
+ select_pair_runtime_receipt_with, terminate_runtime_receipt_with,
+ valid_agent_runtime_receipt_with,
};
pub(crate) use process::{
current_instance_id, process_belongs_to_us, process_has_buzz_marker, process_is_running,
terminate_process, terminate_untracked_pair_runtime, valid_agent_runtime_receipt,
+ with_pair_runtime_receipt_authority,
};
mod orphan_sweep;
@@ -108,8 +110,8 @@ fn persona_drift_state(
/// pin is ignored — see `effective_agent_relay_url`). Returns `None` for
/// records that cannot form a valid pair key yet (e.g. key-less agents that
/// mint keys on first start).
-pub(crate) fn workspace_pair_key(
- app: &AppHandle,
+pub(crate) fn workspace_pair_key(
+ app: &AppHandle,
record: &ManagedAgentRecord,
) -> Option {
let state = app.state::();
@@ -444,8 +446,8 @@ pub(crate) fn spawn_with_effort_proof(
/// publishes the triggering message before this spawn and passes its send
/// timestamp here so the harness's first REQ replays past that message no
/// matter how long the spawn takes. buzz-acp clamps stale floors to ~15 min.
-pub fn spawn_agent_child(
- app: &AppHandle,
+pub fn spawn_agent_child(
+ app: &AppHandle,
record: &ManagedAgentRecord,
relay_url: &str,
lazy: bool,
@@ -466,8 +468,8 @@ pub fn spawn_agent_child(
}
#[allow(clippy::too_many_arguments)]
-pub(crate) fn spawn_agent_child_with_broker(
- app: &AppHandle,
+pub(crate) fn spawn_agent_child_with_broker(
+ app: &AppHandle,
record: &ManagedAgentRecord,
relay_url: &str,
lazy: bool,
@@ -917,8 +919,8 @@ pub(crate) fn spawn_agent_child_with_broker(
/// exact workspace-relay read the caller's scope assertion passed on; it never
/// re-reads the mutable override (see `relay::scope`). The key comes from
/// [`bound_runtime_key`] — the seam the spawn-key regressions exercise.
-pub fn start_managed_agent_process(
- app: &AppHandle,
+pub fn start_managed_agent_process(
+ app: &AppHandle,
record: &mut ManagedAgentRecord,
runtimes: &mut HashMap,
owner_hex: Option<&str>,
@@ -944,6 +946,10 @@ pub fn start_managed_agent_process(
// Scalar PIDs are migration-only and never establish pair liveness.
record.runtime_pid = None;
+ // A prior-session receipt is the only untracked process this pair may
+ // replace. Selection enforces host-preserving authority provenance and
+ // uses the ordinary process-tree termination contract.
+ terminate_untracked_pair_runtime(app, &key)?;
let mut process = spawn_agent_child(
app,
record,
@@ -954,12 +960,12 @@ pub fn start_managed_agent_process(
resume,
)?;
let now = now_iso();
- let receipt = super::ManagedAgentRuntimeReceipt {
- key: key.clone(),
- pid: process.child.id(),
- desktop_instance_id: current_instance_id(app),
- started_at: now.clone(),
- };
+ let receipt = super::ManagedAgentRuntimeReceipt::new(
+ key.clone(),
+ process.child.id(),
+ current_instance_id(app),
+ now.clone(),
+ );
if let Err(error) = super::write_agent_runtime_receipt(app, &receipt) {
let _ = terminate_process(process.child.id());
let _ = process.child.wait();
@@ -979,7 +985,7 @@ pub fn start_managed_agent_process(
}
#[cfg(test)]
-mod test_fixtures;
+pub(super) mod test_fixtures;
#[cfg(test)]
mod tests;
diff --git a/desktop/src-tauri/src/managed_agents/runtime/authority_tests.rs b/desktop/src-tauri/src/managed_agents/runtime/authority_tests.rs
new file mode 100644
index 00000000000..c27762cea80
--- /dev/null
+++ b/desktop/src-tauri/src/managed_agents/runtime/authority_tests.rs
@@ -0,0 +1,364 @@
+//! Runtime authority migration and production Start regressions.
+
+use super::super as runtime;
+use super::receipt_fixture;
+
+#[test]
+fn legacy_receipt_validation_uses_legacy_rendering_for_global_ownership() {
+ let mut receipt = receipt_fixture(
+ crate::managed_agents::ManagedAgentRuntimeKey::new(
+ "aa".repeat(32),
+ "wss://relay.example?mode=one",
+ )
+ .unwrap(),
+ );
+ receipt.authority_version = 0;
+ // url::Url serialization retained the root slash before a query in V0,
+ // while the scoped runtime renderer intentionally removes that root slash.
+ receipt.key.relay_url = "wss://relay.example/?mode=one".into();
+ let path = std::path::PathBuf::from(format!("{}.json", receipt.key.runtime_id()));
+
+ assert!(runtime::valid_agent_runtime_receipt_with(
+ &path,
+ &receipt,
+ "test-instance",
+ |_| true,
+ |_, _| true,
+ ));
+}
+
+#[test]
+fn legacy_normalizer_loss_boundaries_are_pinned_to_the_real_url_renderer() {
+ let normalize = buzz_core_pkg::relay::normalize_relay_url;
+ assert_eq!(
+ normalize("wss://relay.example/room/").unwrap(),
+ "wss://relay.example/room"
+ );
+ assert_eq!(
+ normalize("wss://relay.example/room?tail=/").unwrap(),
+ "wss://relay.example/room?tail="
+ );
+ assert_eq!(
+ normalize("wss://relay.example/?mode=one").unwrap(),
+ "wss://relay.example/?mode=one"
+ );
+ assert_eq!(
+ normalize("wss://relay.example/?").unwrap(),
+ "wss://relay.example/?"
+ );
+}
+
+#[test]
+fn replacement_removes_receipt_only_after_confirmed_exit() {
+ use std::cell::{Cell, RefCell};
+
+ let receipt = receipt_fixture(
+ crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "wss://relay.example")
+ .unwrap(),
+ );
+ let path = std::path::Path::new("pair.json");
+ let terminated = Cell::new(None);
+ let polls = Cell::new(0);
+ let removed = RefCell::new(None);
+
+ runtime::terminate_runtime_receipt_with(
+ path,
+ &receipt,
+ |pid| {
+ terminated.set(Some(pid));
+ Ok(())
+ },
+ |_| {
+ let poll = polls.get() + 1;
+ polls.set(poll);
+ poll < 2
+ },
+ |path| *removed.borrow_mut() = Some(path.to_path_buf()),
+ )
+ .unwrap();
+
+ assert_eq!(terminated.get(), Some(receipt.pid));
+ assert_eq!(polls.get(), 2);
+ assert_eq!(removed.into_inner().as_deref(), Some(path));
+}
+
+#[test]
+fn replacement_failure_keeps_receipt() {
+ use std::cell::Cell;
+
+ let receipt = receipt_fixture(
+ crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "wss://relay.example")
+ .unwrap(),
+ );
+ let removed = Cell::new(false);
+ let error = runtime::terminate_runtime_receipt_with(
+ std::path::Path::new("pair.json"),
+ &receipt,
+ |_| Err("signal failed".into()),
+ |_| false,
+ |_| removed.set(true),
+ )
+ .unwrap_err();
+
+ assert_eq!(error, "signal failed");
+ assert!(!removed.get());
+}
+
+#[cfg(unix)]
+#[test]
+fn production_start_refuses_live_unversioned_receipt_before_spawn() {
+ let _path_guard = crate::managed_agents::lock_path_mutex();
+ let temp = tempfile::tempdir().unwrap();
+ let old_home = std::env::var_os("HOME");
+ let old_xdg = std::env::var_os("XDG_DATA_HOME");
+ struct RestoreEnv(Option, Option);
+ impl Drop for RestoreEnv {
+ fn drop(&mut self) {
+ match self.0.take() {
+ Some(value) => std::env::set_var("HOME", value),
+ None => std::env::remove_var("HOME"),
+ }
+ match self.1.take() {
+ Some(value) => std::env::set_var("XDG_DATA_HOME", value),
+ None => std::env::remove_var("XDG_DATA_HOME"),
+ }
+ }
+ }
+ let _restore_env = RestoreEnv(old_home, old_xdg);
+ std::env::set_var("HOME", temp.path());
+ std::env::set_var("XDG_DATA_HOME", temp.path());
+
+ let relay = "wss://relay.example";
+ let pubkey = "aa".repeat(32);
+ let app = tauri::test::mock_builder()
+ .manage(crate::app_state::build_app_state())
+ .build(tauri::test::mock_context(tauri::test::noop_assets()))
+ .unwrap();
+ let instance_id = runtime::current_instance_id(app.handle());
+ let mut child = runtime::test_fixtures::MarkedTestChild::spawn(&instance_id).unwrap();
+ assert!(runtime::process_has_buzz_marker(child.id(), &instance_id));
+
+ let key = crate::managed_agents::ManagedAgentRuntimeKey::new(&pubkey, relay).unwrap();
+ let receipt = crate::managed_agents::ManagedAgentRuntimeReceipt {
+ authority_version: 0,
+ key: key.clone(),
+ pid: child.id(),
+ desktop_instance_id: instance_id,
+ started_at: "now".into(),
+ };
+ crate::managed_agents::write_agent_runtime_receipt(app.handle(), &receipt).unwrap();
+
+ let mut record = runtime::test_fixtures::fixture(
+ crate::managed_agents::RespondTo::OwnerOnly,
+ Vec::new(),
+ None,
+ );
+ record.pubkey = pubkey;
+ record.acp_command = "a-command-that-must-not-be-resolved".into();
+ let bound = crate::relay::bind_expected_relay_scope(None, relay.into()).unwrap();
+ let mut runtimes = std::collections::HashMap::new();
+
+ let error = runtime::start_managed_agent_process(
+ app.handle(),
+ &mut record,
+ &mut runtimes,
+ None,
+ &bound,
+ None,
+ None,
+ )
+ .unwrap_err();
+ assert!(error.contains("cannot prove the requested community authority"));
+ assert!(!error.contains("crash"));
+ assert!(runtimes.is_empty());
+ assert!(child.child_mut().try_wait().unwrap().is_none());
+ assert!(
+ crate::managed_agents::read_all_agent_runtime_receipts(app.handle())
+ .iter()
+ .any(|(_, candidate)| candidate == &receipt)
+ );
+ crate::managed_agents::remove_agent_runtime_receipt(app.handle(), &key);
+}
+
+#[test]
+fn receipt_selection_refuses_ambiguous_unversioned_loopback_authority() {
+ let mut receipt = receipt_fixture(
+ crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "ws://127.0.0.1:3000")
+ .unwrap(),
+ );
+ receipt.authority_version = 0;
+ let path = std::path::PathBuf::from(format!("{}.json", receipt.key.runtime_id()));
+ for requested_relay in [
+ "ws://127.0.0.1:3000",
+ "ws://localhost:3000",
+ "ws://127.0.0.2:3000",
+ "ws://[::1]:3000",
+ ] {
+ let requested =
+ crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), requested_relay)
+ .unwrap();
+
+ let error = runtime::select_pair_runtime_receipt_with(
+ vec![(path.clone(), receipt.clone())],
+ &requested,
+ "test-instance",
+ |_| true,
+ |_, _| true,
+ )
+ .unwrap_err();
+ assert!(
+ error.contains("cannot prove the requested community authority"),
+ "legacy receipt must not prove {requested_relay}"
+ );
+ }
+}
+
+#[test]
+fn receipt_selection_keeps_versioned_loopback_authorities_disjoint() {
+ let receipt = receipt_fixture(
+ crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "ws://127.0.0.1:3000")
+ .unwrap(),
+ );
+ let path = std::path::PathBuf::from(format!("{}.json", receipt.key.runtime_id()));
+ let requested =
+ crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "ws://localhost:3000")
+ .unwrap();
+
+ let selected = runtime::select_pair_runtime_receipt_with(
+ vec![(path, receipt)],
+ &requested,
+ "test-instance",
+ |_| true,
+ |_, _| true,
+ )
+ .unwrap();
+ assert!(selected.is_none());
+}
+
+#[test]
+fn receipt_selection_refuses_unversioned_non_loopback_lossy_urls() {
+ let pubkey = "aa".repeat(32);
+ for (stored_relay, requested_relay) in [
+ ("wss://relay.example/room", "wss://relay.example/room"),
+ ("wss://relay.example/room", "wss://relay.example/room/"),
+ (
+ "wss://relay.example/?mode=one",
+ "wss://relay.example?mode=one",
+ ),
+ (
+ "wss://relay.example/room?tail=",
+ "wss://relay.example/room?tail=/",
+ ),
+ ] {
+ let mut receipt = receipt_fixture(
+ crate::managed_agents::ManagedAgentRuntimeKey::new(
+ pubkey.clone(),
+ "wss://relay.example",
+ )
+ .unwrap(),
+ );
+ receipt.authority_version = 0;
+ receipt.key.relay_url = stored_relay.into();
+ let path = std::path::PathBuf::from(format!("{}.json", receipt.key.runtime_id()));
+ let requested =
+ crate::managed_agents::ManagedAgentRuntimeKey::new(pubkey.clone(), requested_relay)
+ .unwrap();
+
+ let error = runtime::select_pair_runtime_receipt_with(
+ vec![(path, receipt)],
+ &requested,
+ "test-instance",
+ |_| true,
+ |_, _| true,
+ )
+ .unwrap_err();
+ assert!(
+ error.contains("cannot prove the requested community authority"),
+ "legacy {stored_relay} must not prove {requested_relay}"
+ );
+ }
+}
+
+#[test]
+fn legacy_renderer_collapses_repeated_root_slashes_but_modern_keys_do_not() {
+ let parsed = url::Url::parse("wss://relay.example//").unwrap();
+ assert_eq!(parsed.path(), "//");
+ assert_eq!(
+ buzz_core_pkg::relay::normalize_relay_url("wss://relay.example//").unwrap(),
+ "wss://relay.example"
+ );
+ let root =
+ crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "wss://relay.example")
+ .unwrap();
+ let repeated = crate::managed_agents::ManagedAgentRuntimeKey::new(
+ "aa".repeat(32),
+ "wss://relay.example//",
+ )
+ .unwrap();
+ assert_ne!(root, repeated);
+}
+
+#[test]
+fn receipt_selection_refuses_unversioned_root_authority() {
+ let key =
+ crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "wss://relay.example")
+ .unwrap();
+ let mut receipt = receipt_fixture(key.clone());
+ receipt.authority_version = 0;
+ let path = std::path::PathBuf::from(format!("{}.json", receipt.key.runtime_id()));
+
+ let error = runtime::select_pair_runtime_receipt_with(
+ vec![(path, receipt)],
+ &key,
+ "test-instance",
+ |_| true,
+ |_, _| true,
+ )
+ .unwrap_err();
+ assert!(error.contains("cannot prove the requested community authority"));
+}
+
+#[test]
+fn repeated_root_request_is_refused_for_colliding_unversioned_receipt() {
+ let pubkey = "aa".repeat(32);
+ let stored =
+ crate::managed_agents::ManagedAgentRuntimeKey::new(&pubkey, "wss://relay.example").unwrap();
+ let requested =
+ crate::managed_agents::ManagedAgentRuntimeKey::new(&pubkey, "wss://relay.example//")
+ .unwrap();
+ let mut receipt = receipt_fixture(stored);
+ receipt.authority_version = 0;
+ let path = std::path::PathBuf::from(format!("{}.json", receipt.key.runtime_id()));
+
+ let error = runtime::select_pair_runtime_receipt_with(
+ vec![(path, receipt)],
+ &requested,
+ "test-instance",
+ |_| true,
+ |_, _| true,
+ )
+ .unwrap_err();
+ assert!(error.contains("cannot prove the requested community authority"));
+}
+
+#[test]
+fn receipt_selection_refuses_unknown_future_authority_version() {
+ let key =
+ crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "wss://relay.example")
+ .unwrap();
+ let mut receipt = receipt_fixture(key.clone());
+ receipt.authority_version = crate::managed_agents::RUNTIME_AUTHORITY_RECEIPT_VERSION + 1;
+ let path = std::path::PathBuf::from(format!("{}.json", receipt.key.runtime_id()));
+
+ let error = runtime::select_pair_runtime_receipt_with(
+ vec![(path, receipt)],
+ &key,
+ "test-instance",
+ |_| true,
+ |_, _| true,
+ )
+ .unwrap_err();
+ assert!(error.contains("cannot prove the requested community authority"));
+}
+
+// ── workspace pair-key resolution (summary/stop scoping) ────────────────
diff --git a/desktop/src-tauri/src/managed_agents/runtime/process.rs b/desktop/src-tauri/src/managed_agents/runtime/process.rs
index 26aa26f0747..ac3393394ee 100644
--- a/desktop/src-tauri/src/managed_agents/runtime/process.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime/process.rs
@@ -395,7 +395,9 @@ pub(crate) fn valid_agent_runtime_receipt(
/// Injectable version of `valid_agent_runtime_receipt` for testing.
/// `is_running(pid)` and `has_marker(pid, instance_id)` can be substituted by
-/// test doubles without spawning real processes.
+/// test doubles without spawning real processes. Validity here proves only
+/// instance ownership for global cleanup; pair actions must additionally use
+/// `select_pair_runtime_receipt_with` to establish authority provenance.
pub(crate) fn valid_agent_runtime_receipt_with(
path: &std::path::Path,
receipt: &super::super::ManagedAgentRuntimeReceipt,
@@ -403,12 +405,21 @@ pub(crate) fn valid_agent_runtime_receipt_with(
is_running: impl Fn(u32) -> bool,
has_marker: impl Fn(u32, &str) -> bool,
) -> bool {
- let Ok(canonical) =
+ let key_rendering_is_valid = if receipt.authority_version == 0 {
+ receipt.key.pubkey.len() == 64
+ && receipt
+ .key
+ .pubkey
+ .bytes()
+ .all(|byte| byte.is_ascii_hexdigit())
+ && receipt.key.pubkey == receipt.key.pubkey.to_ascii_lowercase()
+ && buzz_core_pkg::relay::normalize_relay_url(&receipt.key.relay_url)
+ .is_ok_and(|legacy| legacy == receipt.key.relay_url)
+ } else {
ManagedAgentRuntimeKey::new(receipt.key.pubkey.clone(), &receipt.key.relay_url)
- else {
- return false;
+ .is_ok_and(|canonical| canonical == receipt.key)
};
- canonical == receipt.key
+ key_rendering_is_valid
&& path.file_name().and_then(|name| name.to_str())
== Some(&format!("{}.json", receipt.key.runtime_id()))
&& receipt.desktop_instance_id == instance_id
@@ -441,20 +452,93 @@ pub(super) fn terminate_runtime_receipt_with(
))
}
+fn receipt_has_proven_pair_authority(receipt: &super::super::ManagedAgentRuntimeReceipt) -> bool {
+ receipt.authority_version == super::super::RUNTIME_AUTHORITY_RECEIPT_VERSION
+}
+
+fn unversioned_receipt_may_ambiguously_match(
+ receipt: &super::super::ManagedAgentRuntimeReceipt,
+ key: &ManagedAgentRuntimeKey,
+) -> bool {
+ if receipt.authority_version != 0 || !receipt.key.pubkey.eq_ignore_ascii_case(&key.pubkey) {
+ return false;
+ }
+ buzz_core_pkg::relay::normalize_relay_url(&key.relay_url)
+ .is_ok_and(|legacy_relay| legacy_relay == receipt.key.relay_url)
+}
+
+/// Select a receipt only when it proves the requested pair authority.
+///
+/// Unversioned receipts used a lossy normalizer that folded loopback hosts and
+/// stripped every terminal slash. They can establish instance ownership for
+/// global cleanup but cannot prove which new runtime key a pair-scoped action
+/// owns, including an apparently exact root URL: `wss://h//` and `wss://h`
+/// share the V0 rendering but are distinct modern keys.
+pub(crate) fn select_pair_runtime_receipt_with(
+ entries: Vec<(std::path::PathBuf, super::super::ManagedAgentRuntimeReceipt)>,
+ key: &ManagedAgentRuntimeKey,
+ instance_id: &str,
+ is_running: impl Fn(u32) -> bool,
+ has_marker: impl Fn(u32, &str) -> bool,
+) -> Result, String> {
+ let mut selected = None;
+ for (path, receipt) in entries {
+ if !valid_agent_runtime_receipt_with(&path, &receipt, instance_id, &is_running, &has_marker)
+ || !receipt.key.pubkey.eq_ignore_ascii_case(&key.pubkey)
+ {
+ continue;
+ }
+
+ let exact = receipt.key == *key;
+ if (exact && !receipt_has_proven_pair_authority(&receipt))
+ || unversioned_receipt_may_ambiguously_match(&receipt, key)
+ {
+ return Err(
+ "Runtime receipt cannot prove the requested community authority; quit Buzz Desktop normally, then reopen it before retrying this Start or Stop"
+ .into(),
+ );
+ }
+ if exact {
+ selected = Some((path, receipt));
+ }
+ }
+ Ok(selected)
+}
+
+/// Run a pair action only after every live receipt that could name the pair
+/// has proven authority. The check itself performs no process termination.
+pub(crate) fn with_pair_runtime_receipt_authority(
+ app: &AppHandle,
+ key: &ManagedAgentRuntimeKey,
+ effect: impl FnOnce() -> Result,
+) -> Result {
+ let instance_id = current_instance_id(app);
+ select_pair_runtime_receipt_with(
+ super::super::read_all_agent_runtime_receipts(app),
+ key,
+ &instance_id,
+ process_is_running,
+ process_has_buzz_marker,
+ )?;
+ effect()
+}
+
/// Replace a valid prior-session process before registering a new child for
/// the same pair. The caller must hold the runtime transition lock so receipt
/// inspection, termination, spawn, and registration cannot race shutdown or
/// another start.
-pub(crate) fn terminate_untracked_pair_runtime(
- app: &AppHandle,
+pub(crate) fn terminate_untracked_pair_runtime(
+ app: &AppHandle,
key: &ManagedAgentRuntimeKey,
) -> Result<(), String> {
let instance_id = current_instance_id(app);
- let Some((path, receipt)) = super::super::read_all_agent_runtime_receipts(app)
- .into_iter()
- .find(|(path, receipt)| {
- receipt.key == *key && valid_agent_runtime_receipt(path, receipt, &instance_id)
- })
+ let Some((path, receipt)) = select_pair_runtime_receipt_with(
+ super::super::read_all_agent_runtime_receipts(app),
+ key,
+ &instance_id,
+ process_is_running,
+ process_has_buzz_marker,
+ )?
else {
return Ok(());
};
diff --git a/desktop/src-tauri/src/managed_agents/runtime/spawn_key.rs b/desktop/src-tauri/src/managed_agents/runtime/spawn_key.rs
index fe302ffc67e..1869a1f7642 100644
--- a/desktop/src-tauri/src/managed_agents/runtime/spawn_key.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime/spawn_key.rs
@@ -81,4 +81,21 @@ mod tests {
let key = bound_runtime_key(&record, &bound).expect("keyable record and relay");
assert_eq!(key.relay_url, "wss://tenant-a.example");
}
+
+ #[test]
+ fn production_spawn_key_preserves_loopback_community_authority() {
+ let record = record(&"cc".repeat(32), "");
+ let localhost =
+ crate::relay::bind_expected_relay_scope(None, "ws://localhost:3000".to_string())
+ .unwrap();
+ let numeric =
+ crate::relay::bind_expected_relay_scope(None, "ws://127.0.0.1:3000".to_string())
+ .unwrap();
+
+ let localhost_key = bound_runtime_key(&record, &localhost).unwrap();
+ let numeric_key = bound_runtime_key(&record, &numeric).unwrap();
+ assert_eq!(localhost_key.relay_url, "ws://localhost:3000");
+ assert_eq!(numeric_key.relay_url, "ws://127.0.0.1:3000");
+ assert_ne!(localhost_key, numeric_key);
+ }
}
diff --git a/desktop/src-tauri/src/managed_agents/runtime/stop.rs b/desktop/src-tauri/src/managed_agents/runtime/stop.rs
index 0c13937ff27..9b619dc19d0 100644
--- a/desktop/src-tauri/src/managed_agents/runtime/stop.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime/stop.rs
@@ -120,31 +120,33 @@ fn stop_legacy_scalar_pid(
/// pairs in other communities. Clears the matching agent session cache
/// (pair-scoped when a pair key resolves). When no pair is tracked for this
/// workspace, only legacy scalar-PID cleanup runs.
-pub fn stop_managed_agent_workspace_pair(
- app: &AppHandle,
+pub fn stop_managed_agent_workspace_pair(
+ app: &AppHandle,
record: &mut ManagedAgentRecord,
runtimes: &mut HashMap,
) -> Result<(), String> {
use tauri::Manager;
let state = app.state::();
match super::workspace_pair_key(app, record) {
- Some(pair_key) if runtimes.contains_key(&pair_key) => {
- stop_managed_agent_pair(app, record, runtimes, &pair_key)?;
- state.clear_agent_session_cache(&pair_key);
- super::super::remove_agent_pid_file(app, &record.pubkey);
- let now = now_iso();
- record.runtime_pid = None;
- record.updated_at = now.clone();
- record.last_stopped_at = Some(now);
- record.last_error = None;
- record.last_error_code = None;
- }
- Some(pair_key) => {
- // No tracked pair here — a pubkey-wide cache clear would disturb
- // live pairs in other communities, so stay pair-scoped.
- stop_legacy_scalar_pid(app, record)?;
+ Some(pair_key) => super::with_pair_runtime_receipt_authority(app, &pair_key, || {
+ if runtimes.contains_key(&pair_key) {
+ stop_managed_agent_pair(app, record, runtimes, &pair_key)?;
+ super::super::remove_agent_pid_file(app, &record.pubkey);
+ let now = now_iso();
+ record.runtime_pid = None;
+ record.updated_at = now.clone();
+ record.last_stopped_at = Some(now);
+ record.last_error = None;
+ record.last_error_code = None;
+ } else {
+ // No tracked pair here — a pubkey-wide cache clear would
+ // disturb live pairs in other communities, so stay scoped.
+ super::terminate_untracked_pair_runtime(app, &pair_key)?;
+ stop_legacy_scalar_pid(app, record)?;
+ }
state.clear_agent_session_cache(&pair_key);
- }
+ Ok(())
+ })?,
None => {
stop_legacy_scalar_pid(app, record)?;
state.clear_agent_session_caches(&record.pubkey);
@@ -250,4 +252,151 @@ mod tests {
selected.sort_by(|left, right| left.relay_url.cmp(&right.relay_url));
assert_eq!(selected, vec![first, second]);
}
+
+ #[cfg(unix)]
+ fn local_stop_refuses_ambiguous_receipt_before_side_effects(tracked: bool) {
+ use tauri::Manager as _;
+
+ let _path_guard = crate::managed_agents::lock_path_mutex();
+ let temp = tempfile::tempdir().unwrap();
+ let old_home = std::env::var_os("HOME");
+ let old_xdg = std::env::var_os("XDG_DATA_HOME");
+ struct RestoreEnv(Option, Option);
+ impl Drop for RestoreEnv {
+ fn drop(&mut self) {
+ match self.0.take() {
+ Some(value) => std::env::set_var("HOME", value),
+ None => std::env::remove_var("HOME"),
+ }
+ match self.1.take() {
+ Some(value) => std::env::set_var("XDG_DATA_HOME", value),
+ None => std::env::remove_var("XDG_DATA_HOME"),
+ }
+ }
+ }
+ let _restore_env = RestoreEnv(old_home, old_xdg);
+ std::env::set_var("HOME", temp.path());
+ std::env::set_var("XDG_DATA_HOME", temp.path());
+
+ let requested_relay = "wss://relay.example/room/";
+ let stored_relay = "wss://relay.example/room";
+ let pubkey = "aa".repeat(32);
+ let state = crate::app_state::build_app_state();
+ *state.relay_url_override.lock().unwrap() = Some(requested_relay.into());
+ let app = tauri::test::mock_builder()
+ .manage(state)
+ .build(tauri::test::mock_context(tauri::test::noop_assets()))
+ .unwrap();
+ let instance_id = super::super::current_instance_id(app.handle());
+
+ let mut child =
+ Some(super::super::test_fixtures::MarkedTestChild::spawn(&instance_id).unwrap());
+ let pid = child.as_ref().unwrap().id();
+ let _process_guard = super::super::test_fixtures::MarkedProcessGuard::new(pid);
+ assert!(super::super::process_has_buzz_marker(pid, &instance_id));
+
+ let stored_key = ManagedAgentRuntimeKey::new(&pubkey, stored_relay).unwrap();
+ let requested_key = ManagedAgentRuntimeKey::new(&pubkey, requested_relay).unwrap();
+ let receipt = super::super::super::ManagedAgentRuntimeReceipt {
+ authority_version: 0,
+ key: stored_key.clone(),
+ pid,
+ desktop_instance_id: instance_id,
+ started_at: "now".into(),
+ };
+ super::super::super::write_agent_runtime_receipt(app.handle(), &receipt).unwrap();
+
+ let mut record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({
+ "pubkey": pubkey,
+ "name": "test",
+ "private_key_nsec": "",
+ "relay_url": "",
+ "acp_command": "buzz-acp",
+ "agent_command": "buzz-agent",
+ "agent_args": [],
+ "mcp_command": "",
+ "turn_timeout_seconds": 320,
+ "env_vars": {},
+ "created_at": "2026-01-01T00:00:00Z",
+ "updated_at": "before"
+ }))
+ .unwrap();
+ let mut runtimes = HashMap::new();
+ if tracked {
+ let process = crate::managed_agents::ManagedAgentProcess {
+ child: child.take().unwrap().into_child(),
+ log_path: Default::default(),
+ spawn_config:
+ crate::managed_agents::spawn_snapshot::prospective_spawn_config_snapshot(
+ &record,
+ &[],
+ &[],
+ requested_relay,
+ &Default::default(),
+ false,
+ crate::managed_agents::AcpSessionPolicy::Channel,
+ ),
+ setup_mode: false,
+ adapter_availability: None,
+ start_nonce: "test-nonce".into(),
+ };
+ runtimes.insert(
+ requested_key.clone(),
+ ManagedAgentPairRuntime::starting(process),
+ );
+ }
+
+ let cache: crate::managed_agents::config_bridge::SessionConfigCache =
+ serde_json::from_value(serde_json::json!({
+ "configOptions": [],
+ "availableModes": [],
+ "availableModels": [],
+ "currentModel": null,
+ "modelOverridden": false,
+ "gooseNativeConfig": null,
+ "capturedAt": "now"
+ }))
+ .unwrap();
+ app.state::()
+ .put_session_cache(requested_key.clone(), cache);
+
+ let error = stop_managed_agent_workspace_pair(app.handle(), &mut record, &mut runtimes)
+ .unwrap_err();
+ assert!(error.contains("cannot prove the requested community authority"));
+ assert_eq!(record.updated_at, "before");
+ assert!(record.last_stopped_at.is_none());
+ assert!(app
+ .state::()
+ .get_session_cache(&requested_key)
+ .is_some());
+ assert!(
+ super::super::super::read_all_agent_runtime_receipts(app.handle())
+ .iter()
+ .any(|(_, candidate)| candidate == &receipt)
+ );
+
+ if tracked {
+ let runtime = runtimes.get_mut(&requested_key).unwrap();
+ assert!(runtime.child.try_wait().unwrap().is_none());
+ let mut runtime = runtimes.remove(&requested_key).unwrap();
+ let _ = runtime.child.kill();
+ let _ = runtime.child.wait();
+ } else {
+ let child = child.as_mut().unwrap();
+ assert!(child.child_mut().try_wait().unwrap().is_none());
+ }
+ super::super::super::remove_agent_runtime_receipt(app.handle(), &stored_key);
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn tracked_local_stop_has_no_side_effect_before_ambiguous_receipt_refusal() {
+ local_stop_refuses_ambiguous_receipt_before_side_effects(true);
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn untracked_local_stop_has_no_side_effect_before_ambiguous_receipt_refusal() {
+ local_stop_refuses_ambiguous_receipt_before_side_effects(false);
+ }
}
diff --git a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs
index 05e11fc4cdf..841eb1ae647 100644
--- a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs
@@ -1,5 +1,128 @@
use crate::managed_agents::types::{ManagedAgentRecord, RespondTo};
+#[cfg(unix)]
+const MARKED_CHILD_FIXTURE_ENV: &str = "BUZZ_TEST_MARKED_CHILD_FIXTURE";
+#[cfg(unix)]
+const MARKED_CHILD_READY_ENV: &str = "BUZZ_TEST_MARKED_CHILD_READY";
+
+/// Test-executable child whose environment is stable and directly observable
+/// through the production process-marker reader.
+#[cfg(unix)]
+pub(in crate::managed_agents) struct MarkedTestChild {
+ child: Option,
+ _ready_dir: tempfile::TempDir,
+}
+
+#[cfg(unix)]
+impl MarkedTestChild {
+ pub(in crate::managed_agents) fn spawn(instance_id: &str) -> Result