feat(desktop): add persistent Bestie experience (#7223)

## Summary

- add a persistent, community-scoped Bestie designation for local
managed agents
- surface the designated agent in the sidebar, agent library, profile
actions, message toolbar, and draggable floating shortcut
- bloom the floating shortcut into a lightweight compact composer that
reuses the normal DM timeline, reactions, presence, and send behavior
- support message handoff with a bounded snapshot and a full Buzz thread
link so the agent can retrieve the complete conversation

## UX details

- the floating avatar and expanded panel stay above app chrome and drag
as one aligned surface
- closing the expanded panel returns it to its top-right anchor
- each mini-composer opening starts visually fresh while messages sent
during that opening remain conversational
- the designated Bestie's duplicate DM entry is hidden from the regular
DM list
- Bestie actions are suppressed inside the mini timeline to avoid
recursive handoff

## Reliability and maintainability

- preserve existing retention database paths across upgrades
- serialize assignment and deletion, clearing matching assignments
across community scopes before an agent is removed
- fence async conversation resolution against workspace and assignment
changes
- validate stale assignments against existing local agents before hiding
DMs
- share lightweight assignment state across agent cards and keep
protected-feature behavior out of the shared timeline API

## Testing

- `pnpm --dir desktop test` — 5,886 tests passed
- `pnpm --dir desktop exec tsc --noEmit`
- `pnpm --dir desktop exec biome check ...`
- `cargo clippy --manifest-path desktop/src-tauri/Cargo.toml
--all-targets -- -D warnings`
- focused native retention and Bestie assignment/command tests — 34
passed
- `VITE_BUZZ_BESTIE=1 pnpm --dir desktop build:e2e`
- `VITE_BUZZ_BESTIE=1 pnpm --dir desktop exec playwright test
--project=smoke tests/e2e/bestie.spec.ts`
- pre-commit and differential pre-push hooks

## Rollout

The UI remains gated by the `bestie` build feature. Screenshots covering
setup, empty, assigned, floating, and message-handoff states are
included in the PR discussion.

---------

Signed-off-by: Arjun Mahanti <arjun@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Arjun Mahanti
2026-09-02 17:56:17 -04:00
committed by GitHub
parent 6f6093243e
commit ac5a18697c
43 changed files with 3279 additions and 41 deletions

View File

@@ -6,14 +6,16 @@ use super::managed_agent_definition::validate_create_definition;
use crate::{
app_state::AppState,
managed_agents::{
bestie_assignment::{recover_pending_assignment_cleanup, with_agent_assignments_cleared},
build_managed_agent_summary, current_instance_id, ensure_persona_is_active,
find_managed_agent_mut, load_managed_agents, load_personas, load_teams,
managed_agent_avatar_url, normalize_agent_args, resolve_provider_binary,
save_managed_agents, start_managed_agent_process, stop_managed_agent_process,
stop_managed_agent_workspace_pair, sync_managed_agent_processes, try_regenerate_nest,
validate_provider_config, BackendKind, CreateManagedAgentRequest,
CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig,
DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
managed_agent_avatar_url, managed_agents_base_dir, normalize_agent_args,
resolve_provider_binary, save_managed_agents, start_managed_agent_process,
stop_managed_agent_process, stop_managed_agent_workspace_pair,
sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind,
CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentRecord,
ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM,
DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
},
relay::relay_ws_url_with_override,
util::now_iso,
@@ -1087,6 +1089,20 @@ pub async fn stop_managed_agent(
// Async so the blocking body (disk reads/writes, process termination, keyring
// delete, nest regeneration) runs off the main UI thread via spawn_blocking.
fn run_managed_agent_deletion<T>(
base_dir: &std::path::Path,
pubkey: &str,
records: &mut Vec<ManagedAgentRecord>,
delete: impl FnOnce(&mut Vec<ManagedAgentRecord>) -> Result<T, String>,
) -> Result<T, String> {
recover_pending_assignment_cleanup(base_dir, |pending_pubkey| {
records
.iter()
.any(|record| record.pubkey.eq_ignore_ascii_case(pending_pubkey))
})?;
with_agent_assignments_cleared(base_dir, pubkey, || delete(records))
}
#[tauri::command]
pub async fn delete_managed_agent(
pubkey: String,
@@ -1102,6 +1118,12 @@ pub async fn delete_managed_agent(
.lock()
.map_err(|error| error.to_string())?;
let mut records = load_managed_agents(&app)?;
let base_dir = managed_agents_base_dir(&app)?;
recover_pending_assignment_cleanup(&base_dir, |pending_pubkey| {
records
.iter()
.any(|record| record.pubkey.eq_ignore_ascii_case(pending_pubkey))
})?;
let mut runtimes = state
.managed_agent_processes
.lock()
@@ -1135,16 +1157,17 @@ pub async fn delete_managed_agent(
}
}
if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) {
stop_managed_agent_process(&app, record, &mut runtimes)?;
}
state.clear_agent_session_caches(&pubkey);
let initial_len = records.len();
records.retain(|record| record.pubkey != pubkey);
if records.len() == initial_len {
if !records.iter().any(|record| record.pubkey == pubkey) {
return Err(format!("agent {pubkey} not found"));
}
save_managed_agents(&app, &records)?;
run_managed_agent_deletion(&base_dir, &pubkey, &mut records, |records| {
if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) {
stop_managed_agent_process(&app, record, &mut runtimes)?;
}
state.clear_agent_session_caches(&pubkey);
records.retain(|record| record.pubkey != pubkey);
save_managed_agents(&app, records)
})?;
crate::managed_agents::delete_agent_key(&pubkey);
// Tombstone after confirmed removal (inside lock; every published
// agent tombstones). The NIP-IA kind:9035 archive request — which

View File

@@ -188,6 +188,45 @@ fn deploy_resolver_inherits_global_when_definition_blank() {
);
}
#[test]
fn production_delete_orchestration_restores_bestie_when_agent_save_fails() {
use crate::managed_agents::{
bestie_assignment::{assignment_matches, replace_assignment},
retention::open_retention_db,
};
let dir = tempfile::tempdir().unwrap_or_else(|error| panic!("temp dir: {error}"));
let retention_dir = dir.path().join("retention");
std::fs::create_dir_all(&retention_dir)
.unwrap_or_else(|error| panic!("create retention dir: {error}"));
let db_path = retention_dir.join("owner.db");
let pubkey = "a".repeat(64);
replace_assignment(
&mut open_retention_db(&db_path)
.unwrap_or_else(|error| panic!("open assignment DB: {error}")),
&pubkey,
)
.unwrap_or_else(|error| panic!("seed assignment: {error}"));
let mut record = bare_agent_record(None, None, None);
record.pubkey.clone_from(&pubkey);
let mut records = vec![record];
let result = run_managed_agent_deletion(dir.path(), &pubkey, &mut records, |_records| {
Err::<(), _>("injected managed-agent save failure".to_string())
});
assert_eq!(
result,
Err("injected managed-agent save failure".to_string())
);
assert!(assignment_matches(
&open_retention_db(&db_path)
.unwrap_or_else(|error| panic!("reopen assignment DB: {error}")),
&pubkey,
)
.unwrap_or_else(|error| panic!("read restored assignment: {error}")));
}
/// Deploy resolver falls back to global when both definition and record have none.
#[test]
fn deploy_resolver_falls_back_to_global_when_definition_and_record_have_none() {

View File

@@ -0,0 +1,218 @@
use std::sync::atomic::Ordering;
use tauri::{AppHandle, State};
use crate::{
app_state::AppState,
managed_agents::{
bestie_assignment::{
assignment_matches, clear_assignment, get_assignment,
recover_pending_assignment_cleanup, replace_assignment, BestieAssignment,
},
load_managed_agents, managed_agents_base_dir,
retention::{active_retention_scope, open_retention_db, RetentionScope},
BackendKind, ManagedAgentRecord,
},
models::ChannelInfo,
};
fn canonical_relay(relay_url: &str) -> Result<String, String> {
buzz_core_pkg::relay::normalize_relay_url(relay_url).map_err(|error| error.to_string())
}
fn assert_expected_scope(
scope: &RetentionScope,
expected_relay_url: Option<&str>,
expected_signer_pubkey: Option<&str>,
) -> Result<(), String> {
if let Some(expected) = expected_relay_url {
if canonical_relay(expected)? != canonical_relay(&scope.relay_url)? {
return Err("active community changed while resolving Bestie".to_string());
}
}
if let Some(expected) = expected_signer_pubkey {
if expected.trim().to_ascii_lowercase() != scope.owner_keys.public_key().to_hex() {
return Err("active identity changed while resolving Bestie".to_string());
}
}
Ok(())
}
fn validate_agent_pubkey(pubkey: &str) -> Result<String, String> {
let normalized = pubkey.trim().to_ascii_lowercase();
if normalized.len() != 64
|| !normalized
.chars()
.all(|character| character.is_ascii_hexdigit())
{
return Err("Bestie agent pubkey must be 64 hexadecimal characters".to_string());
}
Ok(normalized)
}
fn require_eligible_local_agent(
records: &[ManagedAgentRecord],
pubkey: &str,
) -> Result<(), String> {
let record = records
.iter()
.find(|record| record.pubkey.eq_ignore_ascii_case(pubkey))
.ok_or_else(|| "assigned Bestie agent no longer exists on this device".to_string())?;
if record.backend != BackendKind::Local {
return Err("only a local managed agent can be your Bestie".to_string());
}
Ok(())
}
fn recover_pending_cleanup(app: &AppHandle, records: &[ManagedAgentRecord]) -> Result<(), String> {
recover_pending_assignment_cleanup(&managed_agents_base_dir(app)?, |pending_pubkey| {
records
.iter()
.any(|record| record.pubkey.eq_ignore_ascii_case(pending_pubkey))
})
}
#[tauri::command]
pub fn get_bestie_assignment(
expected_relay_url: Option<String>,
expected_signer_pubkey: Option<String>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<Option<BestieAssignment>, String> {
let scope = active_retention_scope(&app, &state)?;
assert_expected_scope(
&scope,
expected_relay_url.as_deref(),
expected_signer_pubkey.as_deref(),
)?;
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let records = load_managed_agents(&app)?;
recover_pending_cleanup(&app, &records)?;
let conn = open_retention_db(&scope.db_path)?;
get_assignment(&conn)
}
#[tauri::command]
pub fn assign_bestie(
agent_pubkey: String,
expected_relay_url: Option<String>,
expected_signer_pubkey: Option<String>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<BestieAssignment, String> {
let pubkey = validate_agent_pubkey(&agent_pubkey)?;
let scope = active_retention_scope(&app, &state)?;
assert_expected_scope(
&scope,
expected_relay_url.as_deref(),
expected_signer_pubkey.as_deref(),
)?;
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let records = load_managed_agents(&app)?;
recover_pending_cleanup(&app, &records)?;
require_eligible_local_agent(&records, &pubkey)?;
let mut conn = open_retention_db(&scope.db_path)?;
replace_assignment(&mut conn, &pubkey)
}
#[tauri::command]
pub fn clear_bestie_assignment(
expected_relay_url: Option<String>,
expected_signer_pubkey: Option<String>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
let scope = active_retention_scope(&app, &state)?;
assert_expected_scope(
&scope,
expected_relay_url.as_deref(),
expected_signer_pubkey.as_deref(),
)?;
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let records = load_managed_agents(&app)?;
recover_pending_cleanup(&app, &records)?;
let mut conn = open_retention_db(&scope.db_path)?;
clear_assignment(&mut conn)
}
#[tauri::command]
pub async fn resolve_bestie_conversation(
expected_relay_url: Option<String>,
expected_signer_pubkey: Option<String>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<ChannelInfo, String> {
let generation = state.workspace_apply_generation.load(Ordering::Acquire);
let scope = active_retention_scope(&app, &state)?;
assert_expected_scope(
&scope,
expected_relay_url.as_deref(),
expected_signer_pubkey.as_deref(),
)?;
let assignment = {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let records = load_managed_agents(&app)?;
recover_pending_cleanup(&app, &records)?;
let conn = open_retention_db(&scope.db_path)?;
let assignment = get_assignment(&conn)?
.ok_or_else(|| "choose an agent before opening Bestie".to_string())?;
require_eligible_local_agent(&records, &assignment.agent_pubkey)?;
assignment
};
let owner_pubkey = scope.owner_keys.public_key().to_hex();
let channel = super::dms::open_dm_with_scope(
vec![assignment.agent_pubkey.clone()],
Some(&scope.relay_url),
Some(&owner_pubkey),
&state,
)
.await?;
if state.workspace_apply_generation.load(Ordering::Acquire) != generation {
return Err("active workspace changed while resolving Bestie".to_string());
}
let current_scope = active_retention_scope(&app, &state)?;
assert_expected_scope(&current_scope, Some(&scope.relay_url), Some(&owner_pubkey))?;
let conn = open_retention_db(&scope.db_path)?;
if !assignment_matches(&conn, &assignment.agent_pubkey)? {
return Err("Bestie assignment changed while opening the conversation".to_string());
}
Ok(channel)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relay_scope_accepts_runtime_equivalences() {
assert_eq!(
canonical_relay(" WSS://LOCALHOST:443/ ")
.unwrap_or_else(|error| panic!("canonical relay: {error}")),
"wss://127.0.0.1"
);
}
#[test]
fn pubkeys_are_normalized_and_validated() {
assert_eq!(
validate_agent_pubkey(&"A".repeat(64))
.unwrap_or_else(|error| panic!("valid pubkey: {error}")),
"a".repeat(64)
);
assert!(validate_agent_pubkey("short").is_err());
}
}

View File

@@ -23,6 +23,21 @@ pub async fn open_dm(
expected_relay_url: Option<String>,
expected_signer_pubkey: Option<String>,
state: State<'_, AppState>,
) -> Result<ChannelInfo, String> {
open_dm_with_scope(
pubkeys,
expected_relay_url.as_deref(),
expected_signer_pubkey.as_deref(),
&state,
)
.await
}
pub(crate) async fn open_dm_with_scope(
pubkeys: Vec<String>,
expected_relay_url: Option<&str>,
expected_signer_pubkey: Option<&str>,
state: &AppState,
) -> Result<ChannelInfo, String> {
// Resolve the relay AND the signing identity once for the open + metadata
// read pair. Callers with a captured tenant scope (Projects agent sends)
@@ -34,25 +49,22 @@ pub async fn open_dm(
// tenant-A DM signed as tenant B's identity — fail closed instead, and
// use this exact key snapshot for both the event signature and the
// NIP-98 auth of every request in this command.
let api_base_url = crate::relay::relay_api_base_url_with_override(&state);
assert_expected_relay_scope(expected_relay_url.as_deref(), &api_base_url)?;
let api_base_url = crate::relay::relay_api_base_url_with_override(state);
assert_expected_relay_scope(expected_relay_url, &api_base_url)?;
let keys = state.signing_keys()?;
assert_expected_signer(
expected_signer_pubkey.as_deref(),
&keys.public_key().to_hex(),
)?;
assert_expected_signer(expected_signer_pubkey, &keys.public_key().to_hex())?;
// Submit a kind:41010 dm-open event; the relay replies with the channel id
// in its OK message payload.
let builder = events::build_dm_open(&pubkeys)?;
let result = submit_event_at_with_keys(builder, &state, &api_base_url, &keys).await?;
let result = submit_event_at_with_keys(builder, state, &api_base_url, &keys).await?;
let ack: OpenDmAck = parse_command_response(&result.message)?;
// Re-fetch the channel metadata so the frontend gets the same `ChannelInfo`
// shape as `get_channel_details` — through the same scope-checked base and
// the same pinned identity.
let metadata = query_relay_at_with_keys(
&state,
state,
&api_base_url,
&[serde_json::json!({
"kinds": [39000],

View File

@@ -11,6 +11,7 @@ mod agent_providers;
mod agent_settings;
mod agent_update_rollback;
mod agents;
mod bestie;
mod canvas;
mod channel_reconnect_repair;
mod channel_templates;
@@ -84,6 +85,7 @@ pub use agent_models::*;
pub use agent_providers::*;
pub use agent_settings::*;
pub use agents::*;
pub use bestie::*;
pub use canvas::*;
pub use channel_reconnect_repair::*;
pub use channel_templates::*;

View File

@@ -621,6 +621,10 @@ pub fn run() {
create_channel,
ensure_starter_channels,
open_dm,
get_bestie_assignment,
assign_bestie,
clear_bestie_assignment,
resolve_bestie_conversation,
hide_dm,
get_channel_details,
get_channel_members,

View File

@@ -0,0 +1,595 @@
//! Durable, owner-and-relay-scoped Bestie designation storage.
use std::{
fs,
io::ErrorKind,
path::{Path, PathBuf},
};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use super::{retention::open_retention_db, storage::atomic_write_json_restricted};
const RECOVERY_JOURNAL_FILE: &str = "bestie-assignment-recovery.json";
/// The one durable Bestie designation in a retention scope.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BestieAssignment {
pub agent_pubkey: String,
}
fn ensure_table(conn: &Connection) -> Result<(), String> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS bestie_assignments (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
agent_pubkey TEXT NOT NULL
);",
)
.map_err(|error| format!("failed to create bestie assignment table: {error}"))
}
/// Read the designation for the already-scoped retention database.
pub fn get_assignment(conn: &Connection) -> Result<Option<BestieAssignment>, String> {
ensure_table(conn)?;
conn.query_row(
"SELECT agent_pubkey FROM bestie_assignments WHERE singleton = 1",
[],
|row| {
Ok(BestieAssignment {
agent_pubkey: row.get(0)?,
})
},
)
.optional()
.map_err(|error| format!("failed to read bestie assignment: {error}"))
}
/// Atomically create or replace the one designation in this scope.
pub fn replace_assignment(
conn: &mut Connection,
agent_pubkey: &str,
) -> Result<BestieAssignment, String> {
ensure_table(conn)?;
let normalized = agent_pubkey.trim().to_ascii_lowercase();
let transaction = conn
.transaction()
.map_err(|error| format!("failed to begin bestie assignment transaction: {error}"))?;
transaction
.execute(
"INSERT INTO bestie_assignments (singleton, agent_pubkey)
VALUES (1, ?1)
ON CONFLICT(singleton) DO UPDATE SET agent_pubkey = excluded.agent_pubkey",
params![normalized],
)
.map_err(|error| format!("failed to replace bestie assignment: {error}"))?;
transaction
.commit()
.map_err(|error| format!("failed to commit bestie assignment: {error}"))?;
get_assignment(conn)?.ok_or_else(|| "bestie assignment was not persisted".to_string())
}
/// Clear the designation without changing or stopping the agent.
pub fn clear_assignment(conn: &mut Connection) -> Result<(), String> {
ensure_table(conn)?;
let transaction = conn
.transaction()
.map_err(|error| format!("failed to begin bestie clear transaction: {error}"))?;
transaction
.execute("DELETE FROM bestie_assignments WHERE singleton = 1", [])
.map_err(|error| format!("failed to clear bestie assignment: {error}"))?;
transaction
.commit()
.map_err(|error| format!("failed to commit bestie clear: {error}"))
}
/// Whether the same agent is still designated after an asynchronous operation.
pub fn assignment_matches(conn: &Connection, agent_pubkey: &str) -> Result<bool, String> {
ensure_table(conn)?;
let normalized = agent_pubkey.trim().to_ascii_lowercase();
Ok(get_assignment(conn)?.is_some_and(|assignment| assignment.agent_pubkey == normalized))
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct ScopedAssignment {
agent_pubkey: String,
path: PathBuf,
}
#[derive(Debug, Deserialize, Serialize)]
struct AssignmentRecoveryJournal {
assignments: Vec<ScopedAssignment>,
version: u8,
}
fn recovery_journal_path(base_dir: &Path) -> PathBuf {
base_dir.join(RECOVERY_JOURNAL_FILE)
}
fn persist_recovery_journal(
base_dir: &Path,
assignments: &[ScopedAssignment],
) -> Result<(), String> {
fs::create_dir_all(base_dir)
.map_err(|error| format!("failed to create agents directory: {error}"))?;
let payload = serde_json::to_vec_pretty(&AssignmentRecoveryJournal {
assignments: assignments.to_vec(),
version: 1,
})
.map_err(|error| format!("failed to serialize Bestie recovery journal: {error}"))?;
atomic_write_json_restricted(&recovery_journal_path(base_dir), &payload)
.map_err(|error| format!("failed to persist Bestie recovery journal: {error}"))
}
fn load_recovery_journal(base_dir: &Path) -> Result<Option<AssignmentRecoveryJournal>, String> {
let path = recovery_journal_path(base_dir);
let payload = match fs::read(&path) {
Ok(payload) => payload,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(format!(
"failed to read Bestie recovery journal {}: {error}",
path.display()
))
}
};
let journal: AssignmentRecoveryJournal = serde_json::from_slice(&payload)
.map_err(|error| format!("failed to parse Bestie recovery journal: {error}"))?;
if journal.version != 1 {
return Err(format!(
"unsupported Bestie recovery journal version {}",
journal.version
));
}
let retention_dir = base_dir.join("retention");
for assignment in &journal.assignments {
if assignment.path.parent() != Some(retention_dir.as_path())
|| assignment.path.extension().and_then(|value| value.to_str()) != Some("db")
{
return Err(format!(
"Bestie recovery journal contains an invalid retention path: {}",
assignment.path.display()
));
}
}
Ok(Some(journal))
}
fn remove_recovery_journal(base_dir: &Path) -> Result<(), String> {
let path = recovery_journal_path(base_dir);
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
Err(error) => Err(format!(
"failed to remove Bestie recovery journal {}: {error}",
path.display()
)),
}
}
fn retention_db_paths(base_dir: &Path) -> Result<Vec<PathBuf>, String> {
let retention_dir = base_dir.join("retention");
let entries = match fs::read_dir(&retention_dir) {
Ok(entries) => entries,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => {
return Err(format!(
"failed to read retention directory {}: {error}",
retention_dir.display()
))
}
};
let mut paths = Vec::new();
for entry in entries {
let entry = entry.map_err(|error| {
format!(
"failed to inspect retention directory {}: {error}",
retention_dir.display()
)
})?;
let path = entry.path();
if path.extension().and_then(|extension| extension.to_str()) != Some("db") {
continue;
}
paths.push(path);
}
paths.sort();
Ok(paths)
}
fn matching_assignments(
base_dir: &Path,
agent_pubkey: &str,
) -> Result<Vec<ScopedAssignment>, String> {
let normalized = agent_pubkey.trim().to_ascii_lowercase();
let mut assignments = Vec::new();
// Read and validate every scope before mutating any of them. A broken later
// database therefore cannot leave an already-cleared prefix behind.
for path in retention_db_paths(base_dir)? {
let conn = open_retention_db(&path)?;
ensure_table(&conn)?;
if assignment_matches(&conn, &normalized)? {
assignments.push(ScopedAssignment {
agent_pubkey: normalized.clone(),
path,
});
}
}
Ok(assignments)
}
fn clear_scope(assignment: &ScopedAssignment) -> Result<(), String> {
let conn = open_retention_db(&assignment.path)?;
conn.execute(
"DELETE FROM bestie_assignments WHERE singleton = 1 AND agent_pubkey = ?1",
params![assignment.agent_pubkey],
)
.map_err(|error| {
format!(
"failed to clear bestie assignment in {}: {error}",
assignment.path.display()
)
})?;
Ok(())
}
fn apply_to_assignments(
assignments: &[ScopedAssignment],
mut apply: impl FnMut(&ScopedAssignment) -> Result<(), String>,
action: &str,
) -> Result<(), String> {
let mut failures = Vec::new();
for assignment in assignments {
if let Err(error) = apply(assignment) {
failures.push(format!("{}: {error}", assignment.path.display()));
}
}
if failures.is_empty() {
Ok(())
} else {
Err(format!(
"failed to {action} Bestie assignments: {}",
failures.join("; ")
))
}
}
fn restore_scope(assignment: &ScopedAssignment) -> Result<(), String> {
let mut conn = open_retention_db(&assignment.path)?;
replace_assignment(&mut conn, &assignment.agent_pubkey).map(|_| ())
}
fn restore_assignments(assignments: &[ScopedAssignment]) -> Result<(), String> {
apply_to_assignments(assignments, restore_scope, "restore")
}
/// Replay a durable interrupted-deletion journal.
///
/// The managed-agent store is authoritative for which side of the operation
/// committed: a retained agent gets its exact pre-delete assignments restored;
/// an absent agent gets those exact assignments cleared. The journal is only
/// removed after every scope reaches that deterministic state.
pub fn recover_pending_assignment_cleanup(
base_dir: &Path,
agent_exists: impl FnOnce(&str) -> bool,
) -> Result<(), String> {
let Some(journal) = load_recovery_journal(base_dir)? else {
return Ok(());
};
let agent_pubkey = journal
.assignments
.first()
.map(|assignment| assignment.agent_pubkey.as_str())
.ok_or_else(|| "Bestie recovery journal contains no assignments".to_string())?;
if journal
.assignments
.iter()
.any(|assignment| assignment.agent_pubkey != agent_pubkey)
{
return Err("Bestie recovery journal contains multiple agents".to_string());
}
if agent_exists(agent_pubkey) {
restore_assignments(&journal.assignments)?;
} else {
apply_to_assignments(&journal.assignments, clear_scope, "clear")?;
}
remove_recovery_journal(base_dir)
}
fn clear_scoped_assignments(
assignments: &[ScopedAssignment],
mut clear: impl FnMut(&ScopedAssignment) -> Result<(), String>,
) -> Result<(), String> {
for assignment in assignments {
clear(assignment)?;
}
Ok(())
}
fn rollback_with_journal<T>(
base_dir: &Path,
assignments: &[ScopedAssignment],
error: String,
restore: impl FnMut(&ScopedAssignment) -> Result<(), String>,
) -> Result<T, String> {
match apply_to_assignments(assignments, restore, "restore") {
Ok(()) => match remove_recovery_journal(base_dir) {
Ok(()) => Err(error),
Err(journal_error) => Err(format!("{error}; {journal_error}")),
},
Err(restore_error) => Err(format!("{error}; {restore_error}")),
}
}
fn with_agent_assignments_cleared_using<T>(
base_dir: &Path,
agent_pubkey: &str,
delete: impl FnOnce() -> Result<T, String>,
clear: impl FnMut(&ScopedAssignment) -> Result<(), String>,
mut restore: impl FnMut(&ScopedAssignment) -> Result<(), String>,
) -> Result<T, String> {
if load_recovery_journal(base_dir)?.is_some() {
return Err("pending Bestie assignment recovery must complete before deletion".to_string());
}
let assignments = matching_assignments(base_dir, agent_pubkey)?;
if assignments.is_empty() {
return delete();
}
persist_recovery_journal(base_dir, &assignments)?;
if let Err(error) = clear_scoped_assignments(&assignments, clear) {
return rollback_with_journal(base_dir, &assignments, error, &mut restore);
}
match delete() {
Ok(value) => {
if let Err(error) = remove_recovery_journal(base_dir) {
// The authoritative managed-agent write already committed.
// Keep the journal as a durable cleanup record; launch/command
// recovery will observe the absent agent, re-clear these exact
// scopes idempotently, and retry journal removal.
eprintln!("buzz-desktop: {error}; cleanup will retry");
}
Ok(value)
}
Err(error) => rollback_with_journal(base_dir, &assignments, error, &mut restore),
}
}
/// Run agent deletion work with this agent's community-scoped Bestie
/// assignments temporarily cleared.
///
/// Call this while holding `managed_agents_store_lock`. Every matching scope is
/// snapshotted before the first write. A partial clear, or any later stop/save
/// failure returned by `delete`, restores the snapshot before the error is
/// propagated. Assignments remain cleared only when `delete` succeeds.
pub fn with_agent_assignments_cleared<T>(
base_dir: &Path,
agent_pubkey: &str,
delete: impl FnOnce() -> Result<T, String>,
) -> Result<T, String> {
with_agent_assignments_cleared_using(base_dir, agent_pubkey, delete, clear_scope, restore_scope)
}
#[cfg(test)]
mod tests {
use super::*;
fn connection() -> Connection {
Connection::open_in_memory().unwrap_or_else(|error| panic!("open test db: {error}"))
}
#[test]
fn assignment_is_singleton_and_idempotent() {
let mut conn = connection();
let first = replace_assignment(&mut conn, &"A".repeat(64))
.unwrap_or_else(|error| panic!("assign first: {error}"));
assert_eq!(first.agent_pubkey, "a".repeat(64));
let same = replace_assignment(&mut conn, &"a".repeat(64))
.unwrap_or_else(|error| panic!("reassign same: {error}"));
assert_eq!(same.agent_pubkey, "a".repeat(64));
let replaced = replace_assignment(&mut conn, &"b".repeat(64))
.unwrap_or_else(|error| panic!("replace: {error}"));
assert_eq!(replaced.agent_pubkey, "b".repeat(64));
}
#[test]
fn stale_resolver_is_fenced_after_replace_and_clear_is_idempotent() {
let mut conn = connection();
replace_assignment(&mut conn, &"a".repeat(64))
.unwrap_or_else(|error| panic!("assign: {error}"));
replace_assignment(&mut conn, &"b".repeat(64))
.unwrap_or_else(|error| panic!("replace: {error}"));
assert!(!assignment_matches(&conn, &"a".repeat(64))
.unwrap_or_else(|error| panic!("check stale assignment: {error}")));
clear_assignment(&mut conn).unwrap_or_else(|error| panic!("clear: {error}"));
clear_assignment(&mut conn).unwrap_or_else(|error| panic!("clear again: {error}"));
assert_eq!(
get_assignment(&conn).unwrap_or_else(|error| panic!("read: {error}")),
None
);
}
#[test]
fn deleting_agent_clears_every_matching_scope_and_preserves_other_assignments() {
let dir = tempfile::tempdir().unwrap_or_else(|error| panic!("temp dir: {error}"));
let retention_dir = dir.path().join("retention");
fs::create_dir_all(&retention_dir)
.unwrap_or_else(|error| panic!("create retention dir: {error}"));
let agent = "a".repeat(64);
let other = "b".repeat(64);
let first_path = retention_dir.join("first.db");
let second_path = retention_dir.join("second.db");
let third_path = retention_dir.join("third.db");
replace_assignment(
&mut open_retention_db(&first_path)
.unwrap_or_else(|error| panic!("open first db: {error}")),
&agent,
)
.unwrap_or_else(|error| panic!("assign first scope: {error}"));
replace_assignment(
&mut open_retention_db(&second_path)
.unwrap_or_else(|error| panic!("open second db: {error}")),
&agent,
)
.unwrap_or_else(|error| panic!("assign second scope: {error}"));
replace_assignment(
&mut open_retention_db(&third_path)
.unwrap_or_else(|error| panic!("open third db: {error}")),
&other,
)
.unwrap_or_else(|error| panic!("assign third scope: {error}"));
with_agent_assignments_cleared(dir.path(), &agent, || Ok(()))
.unwrap_or_else(|error| panic!("clear agent assignments: {error}"));
assert_eq!(
get_assignment(
&open_retention_db(&first_path)
.unwrap_or_else(|error| panic!("reopen first db: {error}"))
)
.unwrap_or_else(|error| panic!("read first scope: {error}")),
None
);
assert_eq!(
get_assignment(
&open_retention_db(&third_path)
.unwrap_or_else(|error| panic!("reopen third db: {error}"))
)
.unwrap_or_else(|error| panic!("read third scope: {error}"))
.map(|assignment| assignment.agent_pubkey),
Some(other)
);
}
#[test]
fn later_scope_clear_failure_restores_the_already_cleared_prefix() {
let dir = tempfile::tempdir().unwrap_or_else(|error| panic!("temp dir: {error}"));
let retention_dir = dir.path().join("retention");
fs::create_dir_all(&retention_dir)
.unwrap_or_else(|error| panic!("create retention dir: {error}"));
let agent = "a".repeat(64);
for name in ["first.db", "second.db"] {
replace_assignment(
&mut open_retention_db(&retention_dir.join(name))
.unwrap_or_else(|error| panic!("open {name}: {error}")),
&agent,
)
.unwrap_or_else(|error| panic!("assign {name}: {error}"));
}
let result = with_agent_assignments_cleared_using(
dir.path(),
&agent,
|| Ok(()),
|assignment| {
if assignment.path.ends_with("second.db") {
Err("injected later retention DB failure".to_string())
} else {
clear_scope(assignment)
}
},
restore_scope,
);
assert!(result.is_err());
for name in ["first.db", "second.db"] {
let conn = open_retention_db(&retention_dir.join(name))
.unwrap_or_else(|error| panic!("reopen {name}: {error}"));
assert!(assignment_matches(&conn, &agent)
.unwrap_or_else(|error| panic!("read {name}: {error}")));
}
}
fn assert_later_deletion_failure_restores_assignment(failure: &str) {
let dir = tempfile::tempdir().unwrap_or_else(|error| panic!("temp dir: {error}"));
let retention_dir = dir.path().join("retention");
fs::create_dir_all(&retention_dir)
.unwrap_or_else(|error| panic!("create retention dir: {error}"));
let path = retention_dir.join("owner.db");
let agent = "a".repeat(64);
replace_assignment(
&mut open_retention_db(&path)
.unwrap_or_else(|error| panic!("open assignment db: {error}")),
&agent,
)
.unwrap_or_else(|error| panic!("assign agent: {error}"));
let result = with_agent_assignments_cleared(dir.path(), &agent, || {
Err::<(), _>(failure.to_string())
});
assert_eq!(result, Err(failure.to_string()));
let conn = open_retention_db(&path)
.unwrap_or_else(|error| panic!("reopen assignment db: {error}"));
assert!(assignment_matches(&conn, &agent)
.unwrap_or_else(|error| panic!("read restored assignment: {error}")));
}
#[test]
fn stop_failure_after_cleanup_restores_assignment() {
assert_later_deletion_failure_restores_assignment("injected stop failure");
}
#[test]
fn save_failure_after_cleanup_restores_assignment() {
assert_later_deletion_failure_restores_assignment("injected save failure");
}
#[test]
fn failed_rollback_leaves_a_durable_journal_that_repairs_on_restart() {
let dir = tempfile::tempdir().unwrap_or_else(|error| panic!("temp dir: {error}"));
let retention_dir = dir.path().join("retention");
fs::create_dir_all(&retention_dir)
.unwrap_or_else(|error| panic!("create retention dir: {error}"));
let agent = "a".repeat(64);
let first_path = retention_dir.join("first.db");
let second_path = retention_dir.join("second.db");
for path in [&first_path, &second_path] {
replace_assignment(
&mut open_retention_db(path)
.unwrap_or_else(|error| panic!("open {}: {error}", path.display())),
&agent,
)
.unwrap_or_else(|error| panic!("assign {}: {error}", path.display()));
}
let result = with_agent_assignments_cleared_using(
dir.path(),
&agent,
|| Err::<(), _>("injected managed-agent save failure".to_string()),
clear_scope,
|assignment| {
if assignment.path == second_path {
Err("injected restore failure".to_string())
} else {
restore_scope(assignment)
}
},
);
assert!(result
.as_ref()
.is_err_and(|error| error.contains("injected restore failure")));
assert!(recovery_journal_path(dir.path()).exists());
assert!(!assignment_matches(
&open_retention_db(&second_path)
.unwrap_or_else(|error| panic!("reopen second scope: {error}")),
&agent,
)
.unwrap_or_else(|error| panic!("read torn scope: {error}")));
recover_pending_assignment_cleanup(dir.path(), |pubkey| pubkey == agent)
.unwrap_or_else(|error| panic!("replay durable recovery: {error}"));
for path in [&first_path, &second_path] {
assert!(assignment_matches(
&open_retention_db(path)
.unwrap_or_else(|error| panic!("reopen {}: {error}", path.display())),
&agent,
)
.unwrap_or_else(|error| panic!("read repaired {}: {error}", path.display())));
}
assert!(!recovery_journal_path(dir.path()).exists());
}
}

View File

@@ -11,6 +11,7 @@ pub(crate) use agent_env::{
mod agent_description;
pub(crate) use agent_description::{effective_agent_description, record_effective_description};
mod backend;
pub(crate) mod bestie_assignment;
pub(crate) mod claude_config;
pub(crate) mod config_bridge;
pub(crate) mod custom_harnesses;

View File

@@ -1,5 +1,6 @@
use super::{
find_managed_agent_mut, kill_stale_tracked_processes, load_managed_agents, load_personas,
bestie_assignment::recover_pending_assignment_cleanup, find_managed_agent_mut,
kill_stale_tracked_processes, load_managed_agents, load_personas, managed_agents_base_dir,
save_managed_agents, spawn_agent_child, sync_managed_agent_processes, BackendKind,
ManagedAgentProcess,
};
@@ -114,6 +115,11 @@ pub async fn restore_managed_agents_on_launch(
}
let mut records = load_managed_agents(app)?;
recover_pending_assignment_cleanup(&managed_agents_base_dir(app)?, |pending_pubkey| {
records
.iter()
.any(|record| record.pubkey.eq_ignore_ascii_case(pending_pubkey))
})?;
let mut runtimes = state
.managed_agent_processes
.lock()