mirror of
https://github.com/block/buzz.git
synced 2026-11-14 05:12:27 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
218
desktop/src-tauri/src/commands/bestie.rs
Normal file
218
desktop/src-tauri/src/commands/bestie.rs
Normal 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(¤t_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());
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
595
desktop/src-tauri/src/managed_agents/bestie_assignment.rs
Normal file
595
desktop/src-tauri/src/managed_agents/bestie_assignment.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user