fix(desktop): pass-3 corrections C1–C7 (workspace-scoped agent store)

C1 — Production agents-root contract:
- scope_init.rs already had the correct base_dir contract (no extra
  'agents' join); added production-shaped adoption test that mirrors
  the exact managed_agents_base_dir semantics to prevent regression.

C2/C3 — Deadlock removal + store lock:
- workspace.rs: drop rt_transition + store lock BEFORE compensate_drain
  on all three commit-guard failure paths; hold store lock from drain
  through commit so concurrent store writes cannot interleave.
- identity.rs: drain returns Err((stopped, msg)); compensate uses the
  real stopped slice, not []; locks dropped before compensate_drain.

C4 — Captured-scope completion:
- confirm_team_snapshot_import and confirm_agent_snapshot_import: both
  now capture scope at entry, use _at() APIs throughout, validate
  generation before first write, resolve RetentionScope from captured.
- Mesh recovery (recovery.rs): capture full WorkspaceAgentScope at entry;
  validate generation before each write to definitions_dir.
- Restore missing-record stale-child: when find_managed_agent_mut fails
  for a spawned child (record deleted between Phase B and C), terminate
  the child and remove its receipt instead of leaking the process.

C5 — Pre-Ready family in scope initializer:
- ensure_scope_ready gains owner_pubkey parameter.
- New run_pre_ready_family: runs legacy retention migration and persona
  snapshot backfill before writing _ready so a crash leaves the scope
  in a retryable state, not permanently marked Ready with incomplete data.
- workspace.rs guards remain for pre-existing Ready scopes (idempotent).

C6 — Delete dead boot-migration wrappers:
- Deleted backfill_standalone_agents, detach_directory_backed_teams, and
  strip_baked_team_instructions (the #[allow(dead_code)]-suppressed
  app-level wrappers); their _in_dir equivalents are the authoritative
  scoped pipeline entry points.
- Removed tests for the deleted functions from migration_command_tests.rs.

C7 — Structured degradation reporting:
- spawn_event_sync return type changed from Result<(), String> to (): the
  dispatch cannot fail; the false Result contract is removed.
- workspace.rs restore spawn now emits workspace-degraded Tauri event when
  restore_managed_agents_on_launch returns Err, making restore failures
  observable to the UI instead of silently logged.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-08-03 04:16:42 -04:00
co-authored by Will Pfleger
parent d26c15ae15
commit b0cc726d3f
19 changed files with 630 additions and 642 deletions
+71 -11
View File
@@ -339,18 +339,24 @@ pub async fn save_ncryptsec_copy(
/// `managed_agent_runtime_transition` before calling this function.
///
/// Returns:
/// - `Ok(())` when all runtimes were stopped (or there were none).
/// - `Err(msg)` when the drain failed; the caller must NOT proceed with
/// the identity persist and MUST compensate by restarting the stopped set.
/// - `Ok(stopped)` when all runtimes were stopped (or there were none).
/// - `Err((stopped, msg))` when the drain failed; `stopped` contains the
/// entries that were successfully killed before the failure — the caller
/// MUST compensate these and then drop `managed_agent_runtime_transition`
/// BEFORE calling `compensate_drain` (which re-acquires that lock via
/// `start_pair`).
fn drain_managed_agent_runtimes_for_import(
app: &tauri::AppHandle,
state: &AppState,
) -> Result<Vec<crate::managed_agents::DrainJournalEntry>, String> {
) -> Result<
Vec<crate::managed_agents::DrainJournalEntry>,
(Vec<crate::managed_agents::DrainJournalEntry>, String),
> {
let (stopped, _remaining, drain_error) =
crate::managed_agents::drain_scope_runtimes(app, state);
match drain_error {
None => Ok(stopped),
Some(e) => Err(e),
Some(e) => Err((stopped, e)),
}
}
@@ -383,6 +389,35 @@ pub async fn import_identity(
None
};
// ── Layer 1 async: drain the Mesh client when switching away from an active
// scope — mirrors the pre-spawn_blocking Mesh drain in apply_workspace so
// the Mesh client is not left running against a scope that no longer exists
// after the identity import clears the active scope. Best-effort: a drain
// failure is logged and the import proceeds.
if has_active_scope {
#[cfg(feature = "mesh-llm")]
{
// Drain using the current active relay — the import clears the scope
// entirely, so any live Mesh client becomes stale regardless of relay.
let active_relay = lock_state
.capture_active_scope()
.map(|s| s.relay_url.clone())
.unwrap_or_default();
if let Err(error) = crate::commands::mesh_llm::scope_impl::drain_mesh_client_if_stale(
&app_handle,
// Pass an empty string so any client (any relay) is treated
// as stale and drained; identity import invalidates all scopes.
"",
)
.await
{
eprintln!(
"buzz-desktop: Mesh client drain before identity import failed: {error} (active_relay={active_relay})"
);
}
}
}
let result = tokio::task::spawn_blocking(move || {
// NIP-49 backups require a passphrase and decrypt entirely in Rust.
// Raw nsec/hex input follows the existing parser path unchanged.
@@ -407,13 +442,17 @@ pub async fn import_identity(
// new identity. This is the same drain-journal protocol used by
// `apply_workspace`.
//
// `managed_agents_store_lock` is acquired at Layer 2 start (same as
// apply_workspace) so a concurrent save_managed_agents cannot interleave
// with the drain or the scope clear.
//
// On drain failure: compensate by restarting what was stopped, then
// return Err — do NOT proceed with the identity persist. The caller
// (frontend membership-denied flow) must handle the Err and retry.
//
// The transition lock is held through the identity persist and scope
// clear so no concurrent start/reconcile can insert a runtime between
// drain and scope invalidation.
// The transition lock (and store lock) must be DROPPED before calling
// compensate_drain — compensate_drain calls start_pair which re-acquires
// both managed_agent_runtime_transition and managed_agents_store_lock.
let _rt_transition_guard = if has_active_scope {
Some(
state
@@ -425,13 +464,30 @@ pub async fn import_identity(
None
};
let _store_guard = if has_active_scope {
Some(
state
.managed_agents_store_lock
.lock()
.map_err(|e| format!("managed_agents_store_lock poisoned: {e}"))?,
)
} else {
None
};
let stopped_entries = if has_active_scope {
match drain_managed_agent_runtimes_for_import(&app_handle, &state) {
Ok(stopped) => stopped,
Err(drain_err) => {
// Drain failed — compensate and abort.
Err((stopped, drain_err)) => {
// Drain failed — drop the transition lock AND store lock BEFORE
// compensating: compensate_drain calls start_pair which
// re-acquires both managed_agent_runtime_transition and
// managed_agents_store_lock. Holding either here deadlocks.
// Also restore the partial stopped set, not an empty slice.
drop(_store_guard);
drop(_rt_transition_guard);
let comp_err =
crate::managed_agents::compensate_drain(&app_handle, &[]);
crate::managed_agents::compensate_drain(&app_handle, &stopped);
let msg = match comp_err {
Some(comp) => format!(
"identity import drain failed: {drain_err}; compensation failed: {comp}"
@@ -455,10 +511,14 @@ pub async fn import_identity(
});
// If identity persist failed after a successful drain, compensate.
// Drop the transition lock AND store lock BEFORE calling compensate_drain
// for the same reason: start_pair re-acquires both.
let (pubkey, storage) = match commit_result {
Ok(result) => result,
Err(e) => {
if !stopped_entries.is_empty() {
drop(_store_guard);
drop(_rt_transition_guard);
if let Some(comp_err) =
crate::managed_agents::compensate_drain(&app_handle, &stopped_entries)
{
@@ -28,6 +28,7 @@ fn trim_optional(value: Option<String>) -> Option<String> {
mod pending;
pub(in crate::commands) use pending::retain_persona_pending;
pub(in crate::commands) use pending::retain_persona_pending_in_scope;
pub(super) use pending::tombstone_persona_pending;
mod create;
pub use create::create_persona;
@@ -46,6 +46,21 @@ pub(in crate::commands) fn retain_persona_pending(
}
}
/// Retain a persona event using a pre-resolved [`RetentionScope`].
///
/// For snapshot-import callers that already hold a captured scope inside the
/// `managed_agents_store_lock` — avoids re-reading live state at a point where
/// the lock already prevents any workspace switch from succeeding.
pub(in crate::commands) fn retain_persona_pending_in_scope(
scope: &crate::managed_agents::retention::RetentionScope,
persona: &AgentDefinition,
) {
if let Err(e) = prepare_persona_publication_at(&scope.db_path, &scope.owner_keys, persona, None)
{
eprintln!("buzz-desktop: persona-retain: {e}");
}
}
/// Build, sign, and durably retain a persona event in the active relay+owner
/// scope.
///
@@ -18,8 +18,7 @@ use crate::{
decrypt_envelope, parse_chunk_payload, resolve_unlock_secret, ChunkPayload,
LOCKED_CARD_REFUSAL,
},
load_managed_agents, load_personas, save_managed_agents, save_personas, AgentDefinition,
ManagedAgentRecord, RespondTo,
load_managed_agents, AgentDefinition, ManagedAgentRecord, RespondTo,
},
relay::{effective_agent_relay_url, relay_ws_url_with_override, sync_managed_agent_profile},
util::now_iso,
@@ -124,33 +123,12 @@ pub struct AgentSnapshotImportResult {
/// Resolve the behavioral defaults for an incoming agent snapshot.
///
/// This is the single authoritative selection path for all import-time
/// allowlist and behavioral decisions. It is extracted as a pure, testable
/// function so that unit tests exercise the exact production logic rather
/// than a reconstruction of it.
/// Single authoritative selection path for all import-time allowlist and
/// behavioral decisions. Extracted as a pure function for testability.
///
/// # UI contract
///
/// The Keep/Clear toggle is shown whenever `has_source_allowlist` is true
/// (i.e. the raw allowlist is non-empty), regardless of the source mode.
/// The mode (`respond_to` wire string) and the list are independent axes.
///
/// # Decision table
///
/// | Source mode | Non-empty list | keep=true | keep=false |
/// |--------------|----------------|----------------------|-------------------------|
/// | allowlist | yes | preserve mode + list | owner-only + empty |
/// | allowlist | no | **Err** (reject) | **Err** (reject) |
/// | non-allowlist| yes | preserve mode + list | preserve mode + empty |
/// | non-allowlist| no | preserve mode | preserve mode |
///
/// Allowlist-mode + empty list is always rejected: the UI showed no choice
/// and there is no coherent value to write.
///
/// Non-allowlist + non-empty + Clear: preserve the source mode but empty the
/// list. Only allowlist-mode requires a mode downgrade on Clear, because
/// `allowlist` without entries is an invalid state. Non-allowlist modes
/// remain valid with an empty list.
/// Decision: `allowlist` mode + empty list is rejected (invalid state).
/// On `keep_allowlist=false` with `allowlist` mode, downgrades to owner-only.
/// On `keep_allowlist=false` with other modes, preserves mode, clears list.
pub(crate) fn resolve_snapshot_import_behavior(
raw_respond_to: Option<&str>,
raw_allowlist: &[String],
@@ -458,6 +436,13 @@ pub async fn confirm_agent_snapshot_import(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<AgentSnapshotImportResult, String> {
// Capture the active scope at entry — all definition I/O targets this
// scope; the generation is re-validated before the first write in Phase 3a.
let captured_scope = state
.capture_active_scope()
.ok_or("confirm_agent_snapshot_import: no active workspace scope")?;
let definitions_dir = captured_scope.definitions_dir.clone();
// ── Phase 1: validate (no writes) ────────────────────────────────────────
// Locked cards unlock only via this machine's exact key endpoints;
// anything else fails closed here, before key generation.
@@ -468,7 +453,7 @@ pub async fn confirm_agent_snapshot_import(
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
load_managed_agents(&app)?
crate::managed_agents::storage::load_managed_agents_at(&definitions_dir)?
};
decode_snapshot_for_import(&input.file_bytes, owner_keys.as_ref(), &records)?.0
};
@@ -547,8 +532,21 @@ pub async fn confirm_agent_snapshot_import(
.lock()
.map_err(|e| e.to_string())?;
let mut personas = load_personas(&app)?;
let mut records = load_managed_agents(&app)?;
// Re-validate the captured scope's generation before any write; abort
// if the workspace switched since Phase 1.
crate::managed_agents::scope::validate_scope_generation(&captured_scope)
.map_err(|e| format!("confirm_agent_snapshot_import: {e}"))?;
// Resolve retention scope from the captured scope so retain calls
// write to the correct scope's DB even if live state diverged.
let owner_keys_for_retention = state.signing_keys()?;
let retention_scope = crate::managed_agents::retention::retention_scope_from_captured(
&captured_scope,
owner_keys_for_retention,
)?;
let mut personas = crate::managed_agents::load_personas_at(&definitions_dir)?;
let mut records = crate::managed_agents::storage::load_managed_agents_at(&definitions_dir)?;
// Guard against duplicate pubkey (astronomically unlikely but safe).
if records.iter().any(|r| r.pubkey == pubkey) {
@@ -587,10 +585,10 @@ pub async fn confirm_agent_snapshot_import(
};
personas.push(persona.clone());
save_personas(&app, &personas)?;
crate::managed_agents::save_personas_at(&definitions_dir, &personas)?;
// Enqueue the kind:30175 persona event via the retention path.
super::super::pending::retain_persona_pending(&app, &state, &persona);
super::super::pending::retain_persona_pending_in_scope(&retention_scope, &persona);
// Build the managed agent record — no machine-local commands, no
// secrets, no lineage from the snapshot.
@@ -657,12 +655,12 @@ pub async fn confirm_agent_snapshot_import(
};
records.push(record.clone());
save_managed_agents(&app, &records)?;
crate::managed_agents::storage::save_managed_agents_at(&definitions_dir, &records)?;
// Enqueue the kind:30177 managed-agent event via retention.
// (Uses the same pattern as agents.rs::retain_managed_agent_pending
// inlined here to avoid cross-module private-fn access.)
retain_agent_pending(&app, &state, &record);
retain_agent_pending(&retention_scope, &record);
crate::managed_agents::try_regenerate_nest(&app).ok();
@@ -752,7 +750,10 @@ pub async fn confirm_agent_snapshot_import(
/// Inline retention for the managed-agent kind:30177 event — mirrors
/// `agents::retain_managed_agent_pending` without requiring cross-module
/// private function access.
fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgentRecord) {
fn retain_agent_pending(
scope: &crate::managed_agents::retention::RetentionScope,
record: &ManagedAgentRecord,
) {
use crate::managed_agents::{
agent_events::{agent_event_content, build_agent_event},
persona_events::monotonic_created_at,
@@ -762,7 +763,6 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent
use nostr::JsonUtil;
let result = (|| -> Result<(), String> {
let scope = crate::managed_agents::retention::active_retention_scope(app, state)?;
let conn = open_retention_db(&scope.db_path)?;
let content = serde_json::to_string(&agent_event_content(record))
.map_err(|e| format!("failed to serialize agent content: {e}"))?;
+45 -14
View File
@@ -17,8 +17,10 @@ use crate::{
},
managed_agents::{
agent_snapshot::{build_snapshot, AgentSnapshot, AgentSnapshotMemoryEntry, MemoryLevel},
load_managed_agents, load_personas, load_teams, load_teams_readonly, save_managed_agents,
save_personas, save_teams, AgentDefinition, ManagedAgentRecord, TeamRecord,
load_managed_agents, load_managed_agents_at, load_personas, load_personas_at, load_teams,
load_teams_readonly, managed_agents_store_path_at, save_managed_agents_at,
save_personas_at, save_teams_at, teams_store_path_at, AgentDefinition, ManagedAgentRecord,
TeamRecord,
},
relay::{effective_agent_relay_url, relay_ws_url_with_override, sync_managed_agent_profile},
util::now_iso,
@@ -502,6 +504,16 @@ pub async fn confirm_team_snapshot_import(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<TeamSnapshotImportResult, String> {
// ── Scope capture: snapshot the active workspace at entry ─────────────────
// All store reads and writes in Phase 3 use `definitions_dir` derived from
// this captured scope so a concurrent workspace switch cannot redirect them
// to the wrong scope. The generation is re-validated inside the store lock
// before the first write (Phase 3).
let captured_scope = state
.capture_active_scope()
.ok_or("confirm_team_snapshot_import: no active workspace scope — cannot import")?;
let definitions_dir = captured_scope.definitions_dir.clone();
// ── Phase 1: validate (no I/O) ───────────────────────────────────────────
let snapshot = decode_team_snapshot_from_bytes(&input.file_bytes)?;
let now = now_iso();
@@ -631,8 +643,22 @@ pub async fn confirm_team_snapshot_import(
.lock()
.map_err(|e| e.to_string())?;
// Re-validate the captured scope's generation before any write.
// If the workspace switched since Phase 1, abort — writing to the
// new scope would import into the wrong workspace.
crate::managed_agents::scope::validate_scope_generation(&captured_scope)
.map_err(|e| format!("confirm_team_snapshot_import: {e}"))?;
// Resolve retention scope once from the captured workspace scope so
// all retain calls below write to the correct scope's database.
let owner_keys_for_retention = state.signing_keys()?;
let retention_scope = crate::managed_agents::retention::retention_scope_from_captured(
&captured_scope,
owner_keys_for_retention,
)?;
// Guard against duplicate pubkeys (astronomically unlikely).
let existing_records = load_managed_agents(&app)?;
let existing_records = load_managed_agents_at(&definitions_dir)?;
for m in &minted {
if existing_records.iter().any(|r| r.pubkey == m.pubkey) {
return Err(format!(
@@ -646,13 +672,13 @@ pub async fn confirm_team_snapshot_import(
// Distinguish "file exists with content" from "file absent" so rollback
// can delete a file created by the import rather than leaving orphaned
// records.
let agents_store_path = crate::managed_agents::storage::managed_agents_store_path(&app)?;
let agents_store_path = managed_agents_store_path_at(&definitions_dir);
let agents_store_snapshot = match std::fs::read(&agents_store_path) {
Ok(bytes) => Some(bytes),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(format!("failed to snapshot agent store: {e}")),
};
let teams_store_path = crate::managed_agents::teams_store_path(&app)?;
let teams_store_path = teams_store_path_at(&definitions_dir);
let teams_store_snapshot = match std::fs::read(&teams_store_path) {
Ok(bytes) => Some(bytes),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
@@ -700,11 +726,11 @@ pub async fn confirm_team_snapshot_import(
};
// Write all definitions.
let mut personas = load_personas(&app)?;
let mut personas = load_personas_at(&definitions_dir)?;
for m in &minted {
personas.push(m.definition.clone());
}
if let Err(e) = save_personas(&app, &personas) {
if let Err(e) = save_personas_at(&definitions_dir, &personas) {
return Err(rollback_agents(e));
}
@@ -713,15 +739,15 @@ pub async fn confirm_team_snapshot_import(
for m in &minted {
records.push(m.record.clone());
}
if let Err(e) = save_managed_agents(&app, &records) {
if let Err(e) = save_managed_agents_at(&definitions_dir, &records) {
return Err(rollback_agents(e));
}
// Write the team record. `teams` was pre-loaded via the read-only
// loader before any agent commits, so a read/parse failure already
// aborted before any phase-3 write. save_teams sorts and persists.
// aborted before any phase-3 write. save_teams_at sorts and persists.
teams.push(imported_team.clone());
if let Err(e) = save_teams(&app, &teams) {
if let Err(e) = save_teams_at(&definitions_dir, &teams) {
let err = rollback_agents(e);
// Also restore teams store.
let teams_restore = match &teams_store_snapshot {
@@ -741,10 +767,13 @@ pub async fn confirm_team_snapshot_import(
// All writes committed — safe to update in-memory state.
for m in &minted {
crate::commands::personas::retain_persona_pending(&app, &state, &m.definition);
crate::commands::personas::retain_persona_pending_in_scope(
&retention_scope,
&m.definition,
);
}
for m in &minted {
retain_agent_pending(&app, &state, &m.record);
retain_agent_pending(&retention_scope, &m.record);
}
crate::commands::teams::retain_team_pending(&app, &state, &imported_team);
@@ -847,7 +876,10 @@ pub async fn confirm_team_snapshot_import(
/// Inline retention for the managed-agent kind:30177 event — mirrors
/// `commands::personas::snapshot::import::retain_agent_pending`.
fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgentRecord) {
fn retain_agent_pending(
scope: &crate::managed_agents::retention::RetentionScope,
record: &ManagedAgentRecord,
) {
use crate::managed_agents::{
agent_events::{agent_event_content, build_agent_event},
persona_events::monotonic_created_at,
@@ -857,7 +889,6 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent
use nostr::JsonUtil;
let result = (|| -> Result<(), String> {
let scope = crate::managed_agents::retention::active_retention_scope(app, state)?;
let conn = open_retention_db(&scope.db_path)?;
let content = serde_json::to_string(&agent_event_content(record))
.map_err(|e| format!("failed to serialize agent content: {e}"))?;
+62 -33
View File
@@ -186,34 +186,23 @@ pub async fn apply_workspace(
&target_scope_id,
&scope_dir,
&base_dir,
&effective_owner_pubkey,
)?;
// ── Persona snapshot backfill (still in prepare stage) ────────────
// Run before commit so auto-start agents in the newly-ready scope
// have valid persona snapshots available at the first restore pass.
// Best-effort: a failure is logged but does not block the transition.
// ── Legacy retention migration and persona snapshot backfill ─────────
// Both now run inside `ensure_scope_ready` above (as `run_pre_ready_family`),
// before the `_ready` marker is written. They remain here as
// best-effort guards for any pre-existing Ready scope that was
// initialized before these steps were added to the pipeline.
if let Err(e) = crate::managed_agents::backfill_persona_snapshots_at(
&scope_dir,
&state,
) {
eprintln!("buzz-desktop: persona-snapshot backfill failed during prepare: {e}");
eprintln!("buzz-desktop: persona-snapshot backfill guard failed: {e}");
}
// ── Legacy retention migration (still in prepare stage) ───────────
// Migrate legacy retained events into this scope's retention DB BEFORE
// committing the scope as active. Both the retention and definition
// stores must be migrated together so event-sync sees consistent data.
// Best-effort: a failure is logged and does not block the transition.
{
let effective_owner_pubkey_for_retention = match &parsed_keys {
Some(keys) => keys.public_key().to_hex(),
None => state
.keys
.lock()
.map_err(|e| e.to_string())?
.public_key()
.to_hex(),
};
let effective_owner_pubkey_for_retention = effective_owner_pubkey.clone();
let retention_db_path = crate::managed_agents::retention::scoped_retention_db_path(
&base_dir,
&relay_url,
@@ -232,7 +221,7 @@ pub async fn apply_workspace(
"buzz-desktop: adopted {copied} legacy retained event(s) into this community"
),
Err(error) => eprintln!(
"buzz-desktop: legacy retention migration failed during prepare: {error}"
"buzz-desktop: legacy retention migration guard failed: {error}"
),
}
}
@@ -242,6 +231,11 @@ pub async fn apply_workspace(
// through the end of the commit swap so no start/reconcile can insert
// a new runtime into the gap between drain and scope publication.
//
// `managed_agents_store_lock` is acquired immediately after
// `managed_agent_runtime_transition` and held through commit so that
// a concurrent save_managed_agents (e.g., a runtime status flush)
// cannot interleave with the drain or the scope swap.
//
// All fallible guards (relay_url_override, keys, active_agent_scope)
// are acquired BEFORE any field is mutated so a poison or other lock
// failure cannot leave us half-committed with old processes drained.
@@ -250,16 +244,24 @@ pub async fn apply_workspace(
.lock()
.map_err(|e| e.to_string())?;
let _store = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
// Build the journal and drain under the held transition lock.
let (stopped_entries, _remaining, drain_error) =
crate::managed_agents::drain_scope_runtimes(&app, &state);
if let Some(drain_err) = drain_error {
// Drain failed — compensate by restarting what we stopped.
// The rt_transition lock stays held during compensation so the
// runtime map is still protected.
let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries);
// Drop BOTH the transition lock AND the store lock BEFORE calling
// compensate_drain: start_pair re-acquires both
// managed_agent_runtime_transition and managed_agents_store_lock
// — holding either here would deadlock on the non-reentrant Mutex.
drop(_store);
drop(rt_transition);
let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries);
let degraded_msg = match comp_err {
Some(comp) => {
format!("drain failed ({drain_err}); compensation also failed: {comp}")
@@ -274,8 +276,11 @@ pub async fn apply_workspace(
let mut override_guard = match state.relay_url_override.lock() {
Ok(g) => g,
Err(e) => {
let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries);
// Drop _store and rt_transition BEFORE compensate_drain:
// start_pair re-acquires both; holding either here deadlocks.
drop(_store);
drop(rt_transition);
let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries);
let msg = format!(
"commit failed (relay lock poisoned: {e}){}",
comp_err
@@ -287,9 +292,10 @@ pub async fn apply_workspace(
let mut keys_guard = match state.keys.lock() {
Ok(g) => g,
Err(e) => {
let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries);
drop(override_guard);
drop(_store);
drop(rt_transition);
let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries);
let msg = format!(
"commit failed (keys lock poisoned: {e}){}",
comp_err
@@ -301,10 +307,11 @@ pub async fn apply_workspace(
let mut scope_guard = match state.active_agent_scope.lock() {
Ok(g) => g,
Err(e) => {
let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries);
drop(keys_guard);
drop(override_guard);
drop(_store);
drop(rt_transition);
let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries);
let msg = format!(
"commit failed (scope lock poisoned: {e}){}",
comp_err
@@ -360,6 +367,25 @@ pub async fn apply_workspace(
// If blocking returned a drain-failed result, surface it now.
let apply_result = blocking_result?;
if !apply_result.applied {
// The workspace switch failed (drain or commit error). The Mesh client
// may have been drained in the Layer-1 async stage before spawn_blocking
// was entered. Re-arm it so the old scope's sharing state is restored.
#[cfg(feature = "mesh-llm")]
{
let app = restore_app.clone();
tauri::async_runtime::spawn(async move {
let state = app.state::<AppState>();
if let Err(error) =
crate::commands::mesh_llm::restore_mesh_sharing(&app, &state).await
{
eprintln!(
"buzz-desktop: failed to re-arm Mesh after failed workspace switch: {error}"
);
}
crate::mesh_llm::publish_current_status_once(&app, "workspace switch rollback")
.await;
});
}
return Ok(apply_result);
}
@@ -378,14 +404,12 @@ pub async fn apply_workspace(
match crate::managed_agents::retention::active_retention_scope(&restore_app, &state) {
Ok(scope) => {
if let Some(agent_scope) = state.capture_active_scope() {
if let Err(error) = crate::event_sync::spawn_event_sync(
crate::event_sync::spawn_event_sync(
restore_app.clone(),
scope.owner_keys,
scope.db_path,
agent_scope.definitions_dir,
) {
degraded.push(format!("event-sync dispatch failed: {error}"));
}
);
} else {
degraded.push(
"active agent scope unavailable after workspace apply — event sync skipped"
@@ -402,7 +426,8 @@ pub async fn apply_workspace(
// Per-transition restore: always restore the new scope's auto-start agents
// (replaces the launch-only `managed_agent_restore_pending.swap` one-shot).
// Fire-and-forget spawn so the command returns promptly; failures are logged.
// Fire-and-forget spawn so the command returns promptly; restore failures
// are surfaced as a structured `workspace-degraded` event consumed by the UI.
#[cfg(feature = "mesh-llm")]
{
let app = restore_app.clone();
@@ -418,7 +443,9 @@ pub async fn apply_workspace(
if let Err(error) =
restore_managed_agents_on_launch(&app, &state.shutdown_started).await
{
eprintln!("buzz-desktop: failed to restore managed agents: {error}");
let msg = format!("agent restore failed: {error}");
eprintln!("buzz-desktop: {msg}");
let _ = app.emit("workspace-degraded", &msg);
}
});
}
@@ -431,7 +458,9 @@ pub async fn apply_workspace(
if let Err(error) =
restore_managed_agents_on_launch(&app, &state.shutdown_started).await
{
eprintln!("buzz-desktop: failed to restore managed agents: {error}");
let msg = format!("agent restore failed: {error}");
eprintln!("buzz-desktop: {msg}");
let _ = app.emit("workspace-degraded", &msg);
}
});
}
+4 -6
View File
@@ -39,16 +39,15 @@ pub fn run_event_sync(
/// SQLite, and signing work, so it runs on the blocking pool rather than an
/// async worker.
///
/// Returns `Err` when the `spawn_blocking` call itself fails to enqueue (the
/// thread pool is exhausted or the runtime is shutting down). Completion
/// failures from the reconcile task are logged internally and do not reach
/// the caller — only dispatch failure reaches here.
/// The dispatch always succeeds (fire-and-forget); completion failures are
/// logged internally. Callers that need observable failure should emit a
/// workspace degradation event via `emit_workspace_degradation`.
pub fn spawn_event_sync(
app: tauri::AppHandle,
owner_keys: nostr::Keys,
db_path: std::path::PathBuf,
definitions_dir: std::path::PathBuf,
) -> Result<(), String> {
) {
tauri::async_runtime::spawn(async move {
if let Err(e) = tauri::async_runtime::spawn_blocking(move || {
run_event_sync(&app, &owner_keys, &db_path, &definitions_dir);
@@ -58,7 +57,6 @@ pub fn spawn_event_sync(
eprintln!("buzz-desktop: event-sync: spawn_blocking failed: {e}");
}
});
Ok(())
}
/// Reconcile `personas.json` into the persona-event retention store.
@@ -66,6 +66,18 @@ pub fn backfill_persona_snapshots_at(
backfill_persona_snapshots_in_dir(definitions_dir, state)
}
/// Backfill persona snapshots without acquiring the store lock.
///
/// For use during scope initialization (inside `ensure_scope_ready`), where the
/// scope directory is not yet published as `_ready` and no concurrent reader or
/// writer can legally access it. The lock-taking variant (`backfill_persona_snapshots_at`)
/// must be used in all other contexts.
pub(crate) fn backfill_persona_snapshots_pre_ready(
definitions_dir: &std::path::Path,
) -> Result<(), String> {
backfill_persona_snapshots_inner(definitions_dir)
}
fn backfill_persona_snapshots_in_dir(
definitions_dir: &std::path::Path,
state: &AppState,
@@ -75,6 +87,10 @@ fn backfill_persona_snapshots_in_dir(
.lock()
.map_err(|error| error.to_string())?;
backfill_persona_snapshots_inner(definitions_dir)
}
fn backfill_persona_snapshots_inner(definitions_dir: &std::path::Path) -> Result<(), String> {
let mut records = load_managed_agents_at(definitions_dir)?;
let needs_backfill = records
.iter()
@@ -440,6 +456,16 @@ pub async fn restore_managed_agents_on_launch(
SpawnOutcome::Skipped => continue,
SpawnOutcome::Spawned(key, mut process) => {
let Ok(record) = find_managed_agent_mut(&mut records, &pubkey) else {
// Record was deleted between Phase B and Phase C — terminate
// the spawned child and remove its receipt to avoid a leaked
// process with no record to track it.
eprintln!(
"buzz-desktop: restore: record for {} was deleted during spawn; \
terminating stale child",
pubkey
);
let _ = super::terminate_process(process.child.id());
super::remove_agent_runtime_receipt(app, &key);
continue;
};
let now = util::now_iso();
@@ -115,6 +115,32 @@ pub fn active_retention_scope(app: &AppHandle, state: &AppState) -> Result<Reten
})
}
/// Build a `RetentionScope` from a captured [`WorkspaceAgentScope`] and keys.
/// Use instead of [`active_retention_scope`] when a captured scope is held.
/// Derives `base_dir` two levels up from `definitions_dir`.
pub(crate) fn retention_scope_from_captured(
captured: &crate::managed_agents::scope::WorkspaceAgentScope,
owner_keys: nostr::Keys,
) -> Result<RetentionScope, String> {
let base_dir = captured
.definitions_dir
.parent()
.and_then(|p| p.parent())
.ok_or("retention_scope_from_captured: definitions_dir has fewer than two parent levels")?;
let db_path = scoped_retention_db_path(base_dir, &captured.relay_url, &captured.owner_pubkey);
std::fs::create_dir_all(
db_path
.parent()
.ok_or("retention scope path has no parent")?,
)
.map_err(|e| format!("failed to create retention scope directory: {e}"))?;
Ok(RetentionScope {
db_path,
relay_url: captured.relay_url.clone(),
owner_keys,
})
}
/// Snapshot the active relay + owner, but only when it is the scope that owns
/// events delivered by `arrival_relay_url` from `arrival_owner_pubkey`.
///
@@ -91,7 +91,16 @@ pub fn scope_is_ready(scope_dir: &Path) -> bool {
///
/// Idempotent: safe to call on every `apply_workspace`, even if the scope was
/// already initialized. Returns `Ok(())` when the scope is ready to use.
pub fn ensure_scope_ready(scope_id: &str, scope_dir: &Path, base_dir: &Path) -> Result<(), String> {
///
/// `owner_pubkey` is used for the legacy retention DB migration (copying
/// retained events for this owner from the legacy global DB into the scoped
/// DB). Pass the authenticated owner's hex pubkey.
pub fn ensure_scope_ready(
scope_id: &str,
scope_dir: &Path,
base_dir: &Path,
owner_pubkey: &str,
) -> Result<(), String> {
if scope_is_ready(scope_dir) {
return Ok(());
}
@@ -121,6 +130,7 @@ pub fn ensure_scope_ready(scope_id: &str, scope_dir: &Path, base_dir: &Path) ->
// have landed in the target after the rename.
if scope_dir.exists() && scope_dir.join(MANIFEST_FILE).exists() {
run_scoped_migrations(scope_dir)?;
run_pre_ready_family(scope_dir, base_dir, scope_id, owner_pubkey)?;
write_ready_marker(scope_dir)?;
return Ok(());
}
@@ -131,11 +141,14 @@ pub fn ensure_scope_ready(scope_id: &str, scope_dir: &Path, base_dir: &Path) ->
// Build the staged directory.
install_staged(scope_id, scope_dir, base_dir, &init_kind)?;
// Run idempotent scoped migrations (currently a no-op placeholder — the
// boot-time migrations in migration.rs still run pre-scope; per-scope
// migration scheduling is a follow-on once the legacy data is stable).
// Run idempotent scoped migrations.
run_scoped_migrations(scope_dir)?;
// Run pre-Ready family steps: legacy retention migration + persona backfill.
// These must complete before _ready is written so a crash leaves the scope
// in a retry-able state rather than permanently marking incomplete data Ready.
run_pre_ready_family(scope_dir, base_dir, scope_id, owner_pubkey)?;
// Write the ready marker.
write_ready_marker(scope_dir)?;
@@ -184,7 +197,9 @@ fn read_or_create_canonical_claim(
}
// No retention DB yet — use the fallback JSON claim file.
let claim_path = base_dir.join("agents").join(FALLBACK_CLAIM_FILE);
// `base_dir` is already `<app-data>/agents`; the claim file lives
// at `<app-data>/agents/legacy-claim.json` (no extra "agents" join).
let claim_path = base_dir.join(FALLBACK_CLAIM_FILE);
read_or_create_fallback_claim(&claim_path, scope_id)
}
@@ -254,14 +269,17 @@ fn read_or_create_fallback_claim(
}
/// Check whether legacy (unscoped) definition files exist that need adoption.
///
/// `base_dir` is `<app-data>/agents` (not `<app-data>`). Legacy files live
/// directly under `base_dir`; the new scoped layout puts them under
/// `base_dir/scopes/<scope_id>/`. There is no extra "agents" join here.
fn legacy_definitions_exist(base_dir: &Path) -> bool {
let agents_dir = base_dir.join("agents");
// Legacy layout: `agents/managed-agents.json` at the unscoped root.
// New layout puts files under `agents/scopes/<id>/`.
agents_dir.join("managed-agents.json").exists()
|| agents_dir.join("teams.json").exists()
|| agents_dir.join("global-agent-config.json").exists()
|| agents_dir.join("personas.json").exists()
// Legacy layout: files live directly in `<app-data>/agents/`.
// New layout puts files under `<app-data>/agents/scopes/<id>/`.
base_dir.join("managed-agents.json").exists()
|| base_dir.join("teams.json").exists()
|| base_dir.join("global-agent-config.json").exists()
|| base_dir.join("personas.json").exists()
}
/// Build and atomically install the staged scope directory.
@@ -283,14 +301,14 @@ fn install_staged(
// For AdoptedLegacy: copy legacy files into staging.
if matches!(init_kind, ScopeInitKind::AdoptedLegacy) {
let agents_dir = base_dir.join("agents");
// `base_dir` is `<app-data>/agents`; legacy files live directly in it.
for filename in &[
"managed-agents.json",
"teams.json",
"global-agent-config.json",
"personas.json",
] {
let src = agents_dir.join(filename);
let src = base_dir.join(filename);
if src.exists() {
let dst = staging.join(filename);
std::fs::copy(&src, &dst)
@@ -380,7 +398,8 @@ fn run_scoped_migrations(scope_dir: &Path) -> Result<(), String> {
}
// Step 3: refresh legacy builtin agent avatars.
crate::migration::refresh_builtin_agent_avatars_at(scope_dir);
crate::migration::refresh_builtin_agent_avatars_at(scope_dir)
.map_err(|e| format!("scope-init-avatars: {e}"))?;
// Step 4: backfill standalone agents into definition-linked records.
match crate::migration::backfill_standalone_agents_in_dir(scope_dir) {
@@ -397,21 +416,24 @@ fn run_scoped_migrations(scope_dir: &Path) -> Result<(), String> {
}
// Step 6: reconcile legacy command names.
crate::migration::reconcile_legacy_command_names_at(scope_dir);
crate::migration::reconcile_legacy_command_names_at(scope_dir)
.map_err(|e| format!("scope-init-cmd-names: {e}"))?;
// Step 7: reconcile provider mcp_command values.
crate::migration::reconcile_provider_mcp_commands_at(scope_dir);
crate::migration::reconcile_provider_mcp_commands_at(scope_dir)
.map_err(|e| format!("scope-init-mcp-cmds: {e}"))?;
// Step 8: Databricks V1 → V2 provider migration.
crate::migration::reconcile_databricks_v1_to_v2_at(scope_dir);
crate::migration::reconcile_databricks_v1_to_v2_at(scope_dir)
.map_err(|e| format!("scope-init-databricks: {e}"))?;
// Step 9: materialize runtime onto each record.
crate::migration::materialize_agent_runtimes_at(scope_dir);
crate::migration::materialize_agent_runtimes_at(scope_dir)
.map_err(|e| format!("scope-init-materialize: {e}"))?;
// Step 10: validate the final managed-agents.json is parseable JSON before
// writing the Ready marker. Steps 6-9 use `patch_json_records` which logs
// and swallows parse failures internally; this final check ensures we never
// mark a scope Ready when its primary store is corrupt.
// writing the Ready marker. The step-10 backstop ensures the file is still
// valid JSON even after all migrations have run successfully.
let agents_path = scope_dir.join("managed-agents.json");
if agents_path.exists() {
let content = std::fs::read_to_string(&agents_path)
@@ -426,6 +448,52 @@ fn run_scoped_migrations(scope_dir: &Path) -> Result<(), String> {
Ok(())
}
/// Run the pre-Ready family steps: legacy retention migration and persona
/// snapshot backfill. These must complete before the `_ready` marker is
/// written so a crash between migration and marker leaves the scope in a
/// retry-able state rather than permanently marking incomplete data Ready.
///
/// Both steps are idempotent: a second run after a crash is safe.
/// A failure aborts the pre-Ready sequence and propagates to `ensure_scope_ready`,
/// which withholds the `_ready` marker, enabling a clean retry on next launch.
fn run_pre_ready_family(
scope_dir: &Path,
base_dir: &Path,
scope_id: &str,
owner_pubkey: &str,
) -> Result<(), String> {
// Step A: legacy retention migration — copy owned retained events from
// the legacy global retention.db into this scope's scoped DB.
let scope_db_path = base_dir.join("retention").join(format!("{scope_id}.db"));
if let Some(parent) = scope_db_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("scope-init-retention: failed to create retention dir: {e}"))?;
}
match crate::managed_agents::retention::migrate_legacy_retention_db(
base_dir,
&scope_db_path,
owner_pubkey,
) {
Ok(0) => {}
Ok(copied) => eprintln!(
"buzz-desktop: scope-init-retention: adopted {copied} legacy retained event(s)"
),
Err(e) => return Err(format!("scope-init-retention: {e}")),
}
// Step B: persona snapshot backfill — pre-populate `persona_source_version`
// on instances that link a persona but have no version pinned yet, so
// auto-start agents boot from a valid snapshot even on first activation.
// Runs without the store lock because the scope is not yet published as
// _ready and no concurrent reader or writer can legally access it.
if let Err(e) = crate::managed_agents::restore::backfill_persona_snapshots_pre_ready(scope_dir)
{
return Err(format!("scope-init-backfill: {e}"));
}
Ok(())
}
/// Write the `_ready` marker file inside the scope directory, signaling that
/// all migrations are complete and the scope is available for use.
fn write_ready_marker(scope_dir: &Path) -> Result<(), String> {
@@ -488,22 +556,41 @@ mod tests {
use super::*;
use tempfile::TempDir;
/// Create a temporary directory and return it. The agents base dir
/// (`base_dir`) must be `tmp.path().join("agents")` — matching the
/// production layout where `managed_agents_base_dir` returns
/// `<app-data>/agents`. Callers use `make_base_dir_pair` to get both.
#[allow(dead_code)] // Kept as documentation for the pair-based helpers.
fn make_base_dir() -> TempDir {
tempfile::tempdir().expect("tempdir")
}
/// Returns `(TempDir, base_dir)` where `base_dir = tmp.path().join("agents")`.
///
/// Production: `managed_agents_base_dir` returns `<app-data>/agents`.
/// Tests must use that same layout so `legacy_definitions_exist`,
/// `install_staged`, and `read_or_create_canonical_claim` all see files
/// at the correct level.
fn make_base_dir_pair() -> (TempDir, std::path::PathBuf) {
let tmp = tempfile::tempdir().expect("tempdir");
let base_dir = tmp.path().join("agents");
std::fs::create_dir_all(&base_dir).unwrap();
(tmp, base_dir)
}
/// Write the legacy definition files directly into `base_dir`
/// (i.e. `<app-data>/agents/managed-agents.json` etc.).
fn make_legacy_files(base_dir: &Path) {
let agents_dir = base_dir.join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(agents_dir.join("managed-agents.json"), b"[]").unwrap();
std::fs::write(agents_dir.join("teams.json"), b"[]").unwrap();
std::fs::create_dir_all(base_dir).unwrap();
std::fs::write(base_dir.join("managed-agents.json"), b"[]").unwrap();
std::fs::write(base_dir.join("teams.json"), b"[]").unwrap();
}
#[test]
fn test_fresh_no_legacy_scope_initializes_ready() {
let tmp = make_base_dir();
let scope_dir = tmp.path().join("agents").join("scopes").join("testscope");
ensure_scope_ready("testscope", &scope_dir, tmp.path()).unwrap();
let (_tmp, base_dir) = make_base_dir_pair();
let scope_dir = base_dir.join("scopes").join("testscope");
ensure_scope_ready("testscope", &scope_dir, &base_dir, "test_owner").unwrap();
assert!(scope_is_ready(&scope_dir), "scope should be Ready");
// Manifest should indicate FreshNoLegacy.
let manifest: ScopeManifest =
@@ -513,11 +600,11 @@ mod tests {
#[test]
fn test_adopted_legacy_scope_copies_files() {
let tmp = make_base_dir();
make_legacy_files(tmp.path());
let (_tmp, base_dir) = make_base_dir_pair();
make_legacy_files(&base_dir);
let scope_id = "firstscope";
let scope_dir = tmp.path().join("agents").join("scopes").join(scope_id);
ensure_scope_ready(scope_id, &scope_dir, tmp.path()).unwrap();
let scope_dir = base_dir.join("scopes").join(scope_id);
ensure_scope_ready(scope_id, &scope_dir, &base_dir, "test_owner").unwrap();
assert!(scope_is_ready(&scope_dir));
assert!(
scope_dir.join("managed-agents.json").exists(),
@@ -530,16 +617,16 @@ mod tests {
#[test]
fn test_second_scope_legacy_claimed_by_other() {
let tmp = make_base_dir();
make_legacy_files(tmp.path());
let (_tmp, base_dir) = make_base_dir_pair();
make_legacy_files(&base_dir);
// First scope claims.
let scope_a = tmp.path().join("agents").join("scopes").join("scope_a");
ensure_scope_ready("scope_a", &scope_a, tmp.path()).unwrap();
let scope_a = base_dir.join("scopes").join("scope_a");
ensure_scope_ready("scope_a", &scope_a, &base_dir, "test_owner").unwrap();
// Second scope should see LegacyClaimedByOther.
let scope_b = tmp.path().join("agents").join("scopes").join("scope_b");
ensure_scope_ready("scope_b", &scope_b, tmp.path()).unwrap();
let scope_b = base_dir.join("scopes").join("scope_b");
ensure_scope_ready("scope_b", &scope_b, &base_dir, "test_owner").unwrap();
assert!(scope_is_ready(&scope_b));
let manifest: ScopeManifest =
serde_json::from_slice(&std::fs::read(scope_b.join(MANIFEST_FILE)).unwrap()).unwrap();
@@ -559,20 +646,20 @@ mod tests {
#[test]
fn test_idempotent_double_initialize() {
let tmp = make_base_dir();
make_legacy_files(tmp.path());
let scope_dir = tmp.path().join("agents").join("scopes").join("idempotent");
ensure_scope_ready("idempotent", &scope_dir, tmp.path()).unwrap();
let (_tmp, base_dir) = make_base_dir_pair();
make_legacy_files(&base_dir);
let scope_dir = base_dir.join("scopes").join("idempotent");
ensure_scope_ready("idempotent", &scope_dir, &base_dir, "test_owner").unwrap();
// Second call should be a fast no-op.
ensure_scope_ready("idempotent", &scope_dir, tmp.path()).unwrap();
ensure_scope_ready("idempotent", &scope_dir, &base_dir, "test_owner").unwrap();
assert!(scope_is_ready(&scope_dir));
}
#[test]
fn test_staging_cleanup_on_retry() {
let tmp = make_base_dir();
make_legacy_files(tmp.path());
let scope_dir = tmp.path().join("agents").join("scopes").join("retry");
let (_tmp, base_dir) = make_base_dir_pair();
make_legacy_files(&base_dir);
let scope_dir = base_dir.join("scopes").join("retry");
let staging = staging_dir_for(&scope_dir);
// Simulate an interrupted staging directory.
@@ -580,18 +667,19 @@ mod tests {
std::fs::write(staging.join("partial.json"), b"garbage").unwrap();
// ensure_scope_ready should clean it up and succeed.
ensure_scope_ready("retry", &scope_dir, tmp.path()).unwrap();
ensure_scope_ready("retry", &scope_dir, &base_dir, "test_owner").unwrap();
assert!(scope_is_ready(&scope_dir));
assert!(!staging.exists(), "staging dir should be cleaned up");
}
#[test]
fn test_retention_db_claim_takes_precedence() {
let tmp = make_base_dir();
make_legacy_files(tmp.path());
let (_tmp, base_dir) = make_base_dir_pair();
make_legacy_files(&base_dir);
// Pre-plant a retention.db with scope_a's claim.
let retention_db_path = tmp.path().join("retention.db");
// retention.db lives at `base_dir/retention.db` (i.e. `<app-data>/agents/retention.db`).
let retention_db_path = base_dir.join("retention.db");
let conn = Connection::open(&retention_db_path).unwrap();
ensure_migration_table(&conn).unwrap();
conn.execute(
@@ -602,8 +690,8 @@ mod tests {
drop(conn);
// scope_b activates first — retention.db says scope_a owns legacy.
let scope_b = tmp.path().join("agents").join("scopes").join("scope_b");
ensure_scope_ready("scope_b", &scope_b, tmp.path()).unwrap();
let scope_b = base_dir.join("scopes").join("scope_b");
ensure_scope_ready("scope_b", &scope_b, &base_dir, "test_owner").unwrap();
let manifest: ScopeManifest =
serde_json::from_slice(&std::fs::read(scope_b.join(MANIFEST_FILE)).unwrap()).unwrap();
assert!(
@@ -617,8 +705,8 @@ mod tests {
);
// scope_a now activates — should adopt legacy.
let scope_a = tmp.path().join("agents").join("scopes").join("scope_a");
ensure_scope_ready("scope_a", &scope_a, tmp.path()).unwrap();
let scope_a = base_dir.join("scopes").join("scope_a");
ensure_scope_ready("scope_a", &scope_a, &base_dir, "test_owner").unwrap();
let manifest_a: ScopeManifest =
serde_json::from_slice(&std::fs::read(scope_a.join(MANIFEST_FILE)).unwrap()).unwrap();
assert!(matches!(manifest_a.init_kind, ScopeInitKind::AdoptedLegacy));
@@ -631,19 +719,19 @@ mod tests {
/// IGNORE is idempotent) and legacy files are copied correctly.
#[test]
fn test_crash_after_claim_before_staging_resumes_correctly() {
let tmp = make_base_dir();
make_legacy_files(tmp.path());
let (_tmp, base_dir) = make_base_dir_pair();
make_legacy_files(&base_dir);
// Simulate: claim was written into the fallback file but no staging dir exists yet.
let agents_dir = tmp.path().join("agents");
let claim_path = agents_dir.join(FALLBACK_CLAIM_FILE);
std::fs::create_dir_all(&agents_dir).unwrap();
// The fallback claim file lives at `base_dir/legacy-claim.json`
// (no extra "agents" join — base_dir is already `<app-data>/agents`).
let claim_path = base_dir.join(FALLBACK_CLAIM_FILE);
let claim = serde_json::json!({"scope_id": "scope_a"});
std::fs::write(&claim_path, serde_json::to_vec(&claim).unwrap()).unwrap();
// No staging dir exists — retry runs the full staged install from the claim.
let scope_a = tmp.path().join("agents").join("scopes").join("scope_a");
ensure_scope_ready("scope_a", &scope_a, tmp.path()).unwrap();
let scope_a = base_dir.join("scopes").join("scope_a");
ensure_scope_ready("scope_a", &scope_a, &base_dir, "test_owner").unwrap();
assert!(scope_is_ready(&scope_a), "scope must be Ready after retry");
let manifest: ScopeManifest =
@@ -667,10 +755,10 @@ mod tests {
/// overwrite — staging is the only artifact).
#[test]
fn test_crash_during_staging_copy_is_cleaned_on_retry() {
let tmp = make_base_dir();
make_legacy_files(tmp.path());
let (_tmp, base_dir) = make_base_dir_pair();
make_legacy_files(&base_dir);
let scope_dir = tmp.path().join("agents").join("scopes").join("scope_retry");
let scope_dir = base_dir.join("scopes").join("scope_retry");
let staging = staging_dir_for(&scope_dir);
// Simulate interrupted staging: directory exists with partial content.
@@ -678,7 +766,7 @@ mod tests {
std::fs::write(staging.join("managed-agents.json"), b"[\"partial\"]").unwrap();
// No manifest inside staging (write didn't complete).
ensure_scope_ready("scope_retry", &scope_dir, tmp.path()).unwrap();
ensure_scope_ready("scope_retry", &scope_dir, &base_dir, "test_owner").unwrap();
assert!(scope_is_ready(&scope_dir));
assert!(
@@ -698,14 +786,10 @@ mod tests {
/// re-run; the claim is idempotent so the same scope adopts legacy again.
#[test]
fn test_crash_after_staging_manifest_before_rename_resumes_correctly() {
let tmp = make_base_dir();
make_legacy_files(tmp.path());
let (_tmp, base_dir) = make_base_dir_pair();
make_legacy_files(&base_dir);
let scope_dir = tmp
.path()
.join("agents")
.join("scopes")
.join("scope_rename");
let scope_dir = base_dir.join("scopes").join("scope_rename");
let staging = staging_dir_for(&scope_dir);
// Simulate: staging complete with manifest, but rename never fired.
@@ -723,7 +807,7 @@ mod tests {
// Scope dir itself does not exist (rename didn't fire).
assert!(!scope_dir.exists());
ensure_scope_ready("scope_rename", &scope_dir, tmp.path()).unwrap();
ensure_scope_ready("scope_rename", &scope_dir, &base_dir, "test_owner").unwrap();
assert!(scope_is_ready(&scope_dir));
assert!(!staging.exists(), "staging must be cleaned after retry");
@@ -746,14 +830,10 @@ mod tests {
/// outside the scope of the crash-resume path.
#[test]
fn test_crash_after_rename_before_ready_resumes_migrations() {
let tmp = make_base_dir();
make_legacy_files(tmp.path());
let (_tmp, base_dir) = make_base_dir_pair();
make_legacy_files(&base_dir);
let scope_dir = tmp
.path()
.join("agents")
.join("scopes")
.join("scope_pre_ready");
let scope_dir = base_dir.join("scopes").join("scope_pre_ready");
// Simulate: rename already happened — target has manifest + files but no
// _ready marker. Content is valid JSON so migrations can complete.
@@ -772,7 +852,7 @@ mod tests {
// No _ready marker.
assert!(!scope_is_ready(&scope_dir));
ensure_scope_ready("scope_pre_ready", &scope_dir, tmp.path()).unwrap();
ensure_scope_ready("scope_pre_ready", &scope_dir, &base_dir, "test_owner").unwrap();
assert!(
scope_is_ready(&scope_dir),
@@ -794,21 +874,20 @@ mod tests {
/// the defect corrected, migrations complete and `_ready` IS written.
#[test]
fn test_migration_failure_withholds_ready_and_retry_succeeds() {
let tmp = make_base_dir();
let (_tmp, base_dir) = make_base_dir_pair();
// Create the legacy directory with a CORRUPT personas.json.
let agents_dir = tmp.path().join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(agents_dir.join("managed-agents.json"), b"[]").unwrap();
std::fs::create_dir_all(&base_dir).unwrap();
std::fs::write(base_dir.join("managed-agents.json"), b"[]").unwrap();
// Corrupt personas.json: `fold_personas_in_dir` tries to parse it and
// returns Err, which run_scoped_migrations propagates.
std::fs::write(agents_dir.join("personas.json"), b"not valid json").unwrap();
std::fs::write(base_dir.join("personas.json"), b"not valid json").unwrap();
let scope_id = "scope_fail_retry";
let scope_dir = tmp.path().join("agents").join("scopes").join(scope_id);
let scope_dir = base_dir.join("scopes").join(scope_id);
// First attempt: migrations fail, _ready must NOT be written.
let result = ensure_scope_ready(scope_id, &scope_dir, tmp.path());
let result = ensure_scope_ready(scope_id, &scope_dir, &base_dir, "test_owner");
assert!(result.is_err(), "corrupt personas.json must cause Err");
assert!(
!scope_is_ready(&scope_dir),
@@ -816,7 +895,7 @@ mod tests {
);
// Repair the corrupt file.
std::fs::write(agents_dir.join("personas.json"), b"[]").unwrap();
std::fs::write(base_dir.join("personas.json"), b"[]").unwrap();
// Also repair the scope_dir since ensure_scope_ready may have left it in
// a partial state — remove it so the state machine reruns from staging.
if scope_dir.exists() {
@@ -824,10 +903,66 @@ mod tests {
}
// Second attempt: migrations succeed, _ready IS written.
ensure_scope_ready(scope_id, &scope_dir, tmp.path()).unwrap();
ensure_scope_ready(scope_id, &scope_dir, &base_dir, "test_owner").unwrap();
assert!(
scope_is_ready(&scope_dir),
"_ready must be written on successful retry"
);
}
/// Production-contract coverage: `base_dir` is `<app-data>/agents`
/// (the real shape from `managed_agents_base_dir`). Legacy files live
/// at `base_dir/{managed-agents,teams}.json`; the scope dir lives at
/// `base_dir/scopes/<scope_id>/`; the fallback claim file lives at
/// `base_dir/legacy-claim.json`. This test verifies the full adoption
/// path using the production layout so any future double-join regresses
/// visibly here rather than silently succeeding on a synthetic tree.
#[test]
fn test_production_shaped_adoption_finds_legacy_files() {
// `app_data_dir` is the synthetic `<app-data>` root.
let tmp = tempfile::tempdir().expect("tempdir");
let app_data_dir = tmp.path();
// Production: `managed_agents_base_dir` returns `<app-data>/agents`.
let base_dir = app_data_dir.join("agents");
std::fs::create_dir_all(&base_dir).unwrap();
// Legacy files sit directly under `base_dir`.
std::fs::write(base_dir.join("managed-agents.json"), b"[]").unwrap();
std::fs::write(base_dir.join("teams.json"), b"[]").unwrap();
// Scope dir is `base_dir/scopes/<scope_id>/`.
let scope_id = "prod-shape-scope";
let scope_dir = base_dir.join("scopes").join(scope_id);
ensure_scope_ready(scope_id, &scope_dir, &base_dir, "test_owner").unwrap();
assert!(scope_is_ready(&scope_dir), "scope must be Ready");
// With the correct layout the scope must adopt legacy (not start fresh).
let manifest: ScopeManifest =
serde_json::from_slice(&std::fs::read(scope_dir.join(MANIFEST_FILE)).unwrap()).unwrap();
assert!(
matches!(manifest.init_kind, ScopeInitKind::AdoptedLegacy),
"production-layout scope must adopt legacy, got {:?}",
manifest.init_kind
);
// Legacy files were copied into the scope.
assert!(
scope_dir.join("managed-agents.json").exists(),
"managed-agents.json must be present in adopted scope"
);
// Fallback claim file lives at `base_dir/legacy-claim.json`, NOT at
// `base_dir/agents/legacy-claim.json` (which would be the double-join
// path). Verify the correct location was used.
assert!(
base_dir.join(FALLBACK_CLAIM_FILE).exists(),
"fallback claim must be at base_dir/legacy-claim.json, not at a nested path"
);
assert!(
!base_dir.join("agents").join(FALLBACK_CLAIM_FILE).exists(),
"double-join claim path must NOT exist"
);
}
}
+29 -8
View File
@@ -278,11 +278,15 @@ pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Resu
// that commits after this point is handled on the next watchdog cycle.
// Both relay and definitions_dir come from the single captured scope so
// all store reads below target the same workspace as the relay check.
let (scope_relay, scope_definitions_dir) = {
// The generation is captured alongside so writes to definitions_dir can
// detect a mid-pass workspace switch via validate_scope_generation before
// persisting any error/clear record.
let (scope_relay, scope_definitions_dir, captured_scope) = {
let s = state.capture_active_scope();
(
s.as_ref().map(|scope| scope.relay_url.clone()),
s.map(|scope| scope.definitions_dir.clone()),
s.as_ref().map(|scope| scope.definitions_dir.clone()),
s,
)
};
@@ -408,8 +412,17 @@ pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Resu
{
Ok(()) => {
if let Some(dir) = scope_definitions_dir.as_deref() {
if let Err(error) = clear_mesh_last_error_if_set_at(app, dir, &record.pubkey) {
eprintln!("buzz-mesh: failed to clear recovery error: {error}");
// Validate generation before writing to the captured scope's
// directory — if the workspace switched during this await,
// do not write the stale success into the new scope's store.
if captured_scope.as_ref().map_or(false, |s| {
crate::managed_agents::scope::validate_scope_generation(s).is_ok()
}) {
if let Err(error) =
clear_mesh_last_error_if_set_at(app, dir, &record.pubkey)
{
eprintln!("buzz-mesh: failed to clear recovery error: {error}");
}
}
}
}
@@ -418,10 +431,18 @@ pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Resu
"{MESH_REARM_ERROR_SENTINEL}Buzz shared compute offline — failed to re-arm local ingress for this agent: {error}"
);
if let Some(dir) = scope_definitions_dir.as_deref() {
if let Err(persist_error) =
persist_mesh_last_error_at(app, dir, &record.pubkey, &message)
{
eprintln!("buzz-mesh: failed to persist recovery error: {persist_error}");
// Validate generation before persisting the error — if the
// workspace switched during this await, skip the write.
if captured_scope.as_ref().map_or(false, |s| {
crate::managed_agents::scope::validate_scope_generation(s).is_ok()
}) {
if let Err(persist_error) =
persist_mesh_last_error_at(app, dir, &record.pubkey, &message)
{
eprintln!(
"buzz-mesh: failed to persist recovery error: {persist_error}"
);
}
}
}
first_error.get_or_insert(message);
+60 -224
View File
@@ -471,17 +471,23 @@ fn copy_file_over_generated_default(src: &Path, dst: &Path) -> std::io::Result<(
fn patch_json_records(
path: &Path,
mut f: impl FnMut(&mut serde_json::Map<String, serde_json::Value>) -> bool,
) {
let Ok(content) = std::fs::read_to_string(path) else {
return;
) -> Result<(), String> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => {
return Err(format!(
"patch-json-records: failed to read {}: {e}",
path.display()
))
}
};
let Ok(mut records) = serde_json::from_str::<Vec<serde_json::Value>>(&content) else {
eprintln!(
"buzz-desktop: patch-json-records: failed to parse {}",
let mut records = serde_json::from_str::<Vec<serde_json::Value>>(&content).map_err(|e| {
format!(
"patch-json-records: failed to parse {}: {e}",
path.display()
);
return;
};
)
})?;
let mut changed = false;
for record in &mut records {
if let Some(obj) = record.as_object_mut() {
@@ -489,12 +495,15 @@ fn patch_json_records(
}
}
if changed {
if let Ok(bytes) = serde_json::to_vec_pretty(&records) {
if let Err(e) = crate::managed_agents::atomic_write_json_restricted(path, &bytes) {
eprintln!("buzz-desktop: patch-json-records: {e}");
}
}
let bytes = serde_json::to_vec_pretty(&records).map_err(|e| {
format!(
"patch-json-records: failed to serialize {}: {e}",
path.display()
)
})?;
crate::managed_agents::atomic_write_json_restricted(path, &bytes)?;
}
Ok(())
}
struct LegacyBuiltInAvatar<'a> {
@@ -533,41 +542,27 @@ struct LegacyAvatarMatch<'a> {
was_uploaded: bool,
}
/// Refresh the prior seeded avatar on built-in definitions and linked agent
/// instances while preserving any avatar the user customized. Matching by the
/// exact data URL or content-addressed upload digest makes the migration
/// idempotent and avoids relying on timestamps or other persona fields the
/// user may also have edited.
#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses refresh_builtin_agent_avatars_at.
fn refresh_builtin_agent_avatars(app: &tauri::AppHandle) {
let Ok(dir) = app.path().app_data_dir() else {
return;
};
let path = dir.join("agents/managed-agents.json");
if path.exists() {
refresh_builtin_agent_avatars_in_file(
&path,
LEGACY_BUILTIN_AVATARS,
&crate::util::now_iso(),
);
}
}
fn refresh_builtin_agent_avatars_in_file(
path: &Path,
legacy_avatars: &[LegacyBuiltInAvatar<'_>],
now: &str,
) {
let Ok(contents) = std::fs::read_to_string(path) else {
return;
) -> Result<(), String> {
let contents = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => {
return Err(format!(
"refresh-builtin-agent-avatars: failed to read {}: {e}",
path.display()
))
}
};
let Ok(mut records) = serde_json::from_str::<Vec<serde_json::Value>>(&contents) else {
eprintln!(
"buzz-desktop: refresh-builtin-agent-avatars: invalid JSON in {}",
let mut records = serde_json::from_str::<Vec<serde_json::Value>>(&contents).map_err(|e| {
format!(
"refresh-builtin-agent-avatars: invalid JSON in {}: {e}",
path.display()
);
return;
};
)
})?;
// Definitions must be migrated first so linked instances can advance from
// the exact old persona hash to the exact new one. Only advance an instance
@@ -641,12 +636,15 @@ fn refresh_builtin_agent_avatars_in_file(
}
if changed {
if let Ok(bytes) = serde_json::to_vec_pretty(&records) {
if let Err(e) = crate::managed_agents::atomic_write_json_restricted(path, &bytes) {
eprintln!("buzz-desktop: refresh-builtin-agent-avatars: {e}");
}
}
let bytes = serde_json::to_vec_pretty(&records).map_err(|e| {
format!(
"refresh-builtin-agent-avatars: failed to serialize {}: {e}",
path.display()
)
})?;
crate::managed_agents::atomic_write_json_restricted(path, &bytes)?;
}
Ok(())
}
fn legacy_avatar_match<'a>(
@@ -952,7 +950,7 @@ pub fn sync_shared_agent_data(app: &tauri::AppHandle) {
}
}
fn reconcile_mcp_commands_in_file(path: &Path) {
fn reconcile_mcp_commands_in_file(path: &Path) -> Result<(), String> {
// Resolve each record's EFFECTIVE harness (persona-wins, override-honored)
// before deriving its mcp_command, so a persona-inherited harness switch
// doesn't leave a stale persisted mcp_command. The persona runtime is read
@@ -1005,7 +1003,8 @@ fn reconcile_mcp_commands_in_file(path: &Path) {
serde_json::Value::String(expected.to_string()),
);
true
});
})?;
Ok(())
}
fn replace_command_field(
@@ -1029,7 +1028,7 @@ fn replace_command_field(
true
}
fn reconcile_legacy_command_names_in_file(path: &Path) {
fn reconcile_legacy_command_names_in_file(path: &Path) -> Result<(), String> {
patch_json_records(path, |obj| {
let mut changed = false;
@@ -1076,151 +1075,13 @@ fn reconcile_legacy_command_names_in_file(path: &Path) {
}
changed
});
})
}
#[allow(dead_code)] // Helper for reconcile_legacy_command_names (boot-migration shim).
fn reconcile_legacy_persona_runtimes_in_file(path: &Path) {
patch_json_records(path, |obj| {
let Some(runtime) = obj.get("runtime").and_then(|v| v.as_str()) else {
return false;
};
if runtime != "sprout-agent" {
return false;
}
eprintln!(
"buzz-desktop: command-rename-reconcile: persona {:?}: runtime {:?} → {:?}",
obj.get("display_name")
.or_else(|| obj.get("displayName"))
.and_then(|v| v.as_str())
.unwrap_or("?"),
runtime,
"buzz-agent",
);
obj.insert(
"runtime".to_string(),
serde_json::Value::String("buzz-agent".to_string()),
);
true
});
}
#[allow(dead_code)] // Helper for reconcile_legacy_command_names (boot-migration shim).
fn rewrite_legacy_persona_md_runtime(content: &str) -> Option<String> {
let (frontmatter, body) = buzz_persona_pkg::persona::split_frontmatter(content).ok()?;
let mut value = serde_yaml::from_str::<serde_yaml::Value>(frontmatter).ok()?;
let mapping = value.as_mapping_mut()?;
let runtime = mapping.get_mut(serde_yaml::Value::String("runtime".to_string()))?;
if runtime.as_str()? != "sprout-agent" {
return None;
}
*runtime = serde_yaml::Value::String("buzz-agent".to_string());
let frontmatter = serde_yaml::to_string(&value).ok()?;
Some(format!("---\n{frontmatter}---\n{body}"))
}
#[allow(dead_code)] // Helper for reconcile_legacy_command_names (boot-migration shim).
fn reconcile_legacy_team_persona_runtime_files(dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
reconcile_legacy_team_persona_runtime_files(&path);
continue;
}
if !file_type.is_file() {
continue;
}
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
if !name.ends_with(".persona.md") {
continue;
}
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
let Some(updated) = rewrite_legacy_persona_md_runtime(&content) else {
continue;
};
if updated == content {
continue;
}
match std::fs::write(&path, updated) {
Ok(()) => {
eprintln!(
"buzz-desktop: command-rename-reconcile: updated {}",
path.display()
);
}
Err(error) => {
eprintln!(
"buzz-desktop: command-rename-reconcile: failed to update {}: {error}",
path.display()
);
}
}
}
}
/// Reconcile exact built-in command values persisted before the Sprout→Buzz
/// rename. Custom commands and explicit paths are left untouched.
#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses reconcile_legacy_command_names_at.
pub fn reconcile_legacy_command_names(app: &tauri::AppHandle) {
let Ok(current_dir) = app.path().app_data_dir() else {
return;
};
let mut dirs = vec![current_dir.clone()];
if let Some(canonical) = canonical_dev_data_dir(&current_dir) {
if canonical.exists() && canonical != current_dir {
dirs.push(canonical);
}
}
for dir in dirs {
let path = dir.join("agents/managed-agents.json");
if path.exists() {
reconcile_legacy_command_names_in_file(&path);
}
let personas_path = dir.join("agents/personas.json");
if personas_path.exists() {
reconcile_legacy_persona_runtimes_in_file(&personas_path);
}
let teams_dir = dir.join("agents/teams");
if teams_dir.exists() && !teams_dir.is_symlink() {
reconcile_legacy_team_persona_runtime_files(&teams_dir);
}
}
}
/// Reconcile `mcp_command` values in managed-agents.json against the
/// discovery table. Known runtimes get their canonical mcp_command;
/// unknown/custom agents are left untouched. Covers both the current
/// app data dir and the canonical dev data dir (for worktree instances).
#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses reconcile_provider_mcp_commands_at.
pub fn reconcile_provider_mcp_commands(app: &tauri::AppHandle) {
let Ok(current_dir) = app.path().app_data_dir() else {
return;
};
let mut dirs = vec![current_dir.clone()];
if let Some(canonical) = canonical_dev_data_dir(&current_dir) {
if canonical.exists() && canonical != current_dir {
dirs.push(canonical);
}
}
for dir in dirs {
let path = dir.join("agents/managed-agents.json");
if path.exists() {
reconcile_mcp_commands_in_file(&path);
}
}
}
fn reconcile_databricks_v1_to_v2_in_file(path: &Path, rewrite_v1_provider: bool) {
fn reconcile_databricks_v1_to_v2_in_file(
path: &Path,
rewrite_v1_provider: bool,
) -> Result<(), String> {
use crate::managed_agents::is_derived_provider_model_key;
patch_json_records(path, |obj| {
let mut changed = false;
@@ -1277,7 +1138,7 @@ fn reconcile_databricks_v1_to_v2_in_file(path: &Path, rewrite_v1_provider: bool)
}
changed
});
})
}
/// Strip stale derived provider/model keys from `env_vars` in all
@@ -1301,36 +1162,9 @@ fn reconcile_databricks_v1_to_v2_in_file(path: &Path, rewrite_v1_provider: bool)
/// Covers both the current app data dir and the canonical dev data dir
/// (for worktree instances) — same dual-dir pattern as
/// `reconcile_legacy_command_names` and `reconcile_provider_mcp_commands`.
#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses reconcile_databricks_v1_to_v2_at.
pub fn reconcile_databricks_v1_to_v2(app: &tauri::AppHandle) {
use crate::managed_agents::baked_build_env;
// On Block builds, the baked env contains BUZZ_AGENT_PROVIDER=databricks_v2.
// Use that as a reliable signal that this is a Block build and the V1
// provider should be migrated. OSS builds have an empty baked env, so
// rewrite_v1_provider is false and the structured provider is preserved.
let rewrite_v1_provider = baked_build_env()
.get("BUZZ_AGENT_PROVIDER")
.map(|v| v == "databricks_v2")
.unwrap_or(false);
let Ok(current_dir) = app.path().app_data_dir() else {
return;
};
let mut dirs = vec![current_dir.clone()];
if let Some(canonical) = canonical_dev_data_dir(&current_dir) {
if canonical.exists() && canonical != current_dir {
dirs.push(canonical);
}
}
for dir in dirs {
let path = dir.join("agents/managed-agents.json");
if path.exists() {
reconcile_databricks_v1_to_v2_in_file(&path, rewrite_v1_provider);
}
}
}
fn rename_provider_to_runtime_in_personas(path: &Path) {
patch_json_records(path, |obj| {
if let Err(e) = patch_json_records(path, |obj| {
if obj.contains_key("runtime") {
return false;
}
@@ -1340,7 +1174,9 @@ fn rename_provider_to_runtime_in_personas(path: &Path) {
} else {
false
}
});
}) {
eprintln!("buzz-desktop: rename-provider-to-runtime: {e}");
}
}
pub fn migrate_persona_provider_to_runtime(app: &tauri::AppHandle) {
@@ -31,21 +31,6 @@ use crate::managed_agents::{
/// The manufactured definition's slug is the agent's pubkey: 64-hex passes
/// the NIP-AP slug grammar on both relay and desktop ends, and agent pubkeys
/// are unique, so the coordinate is collision-free by construction.
#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses backfill_standalone_agents_in_dir.
pub fn backfill_standalone_agents(app: &tauri::AppHandle) {
let Ok(base_dir) = crate::managed_agents::managed_agents_base_dir(app) else {
return;
};
match backfill_standalone_agents_in_dir(&base_dir) {
Ok(0) => {}
Ok(backfilled) => {
eprintln!(
"buzz-desktop: standalone-backfill: {backfilled} agents linked to manufactured definitions"
);
}
Err(e) => eprintln!("buzz-desktop: standalone-backfill: {e}"),
}
}
/// Core backfill logic, decoupled from the Tauri `AppHandle` for testing.
/// Returns the number of records backfilled (0 = nothing to do).
-12
View File
@@ -24,18 +24,6 @@ use crate::managed_agents::{ManagedAgentRecord, TeamRecord};
/// `instructions` if the field is not already set.
/// 4. Clear `source_dir`, `is_symlink`, `symlink_target`, `version` on each
/// directory-backed `TeamRecord`.
#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses detach_directory_backed_teams_in_dir.
pub fn detach_directory_backed_teams(app: &tauri::AppHandle) {
let Ok(base_dir) = crate::managed_agents::managed_agents_base_dir(app) else {
return;
};
match detach_directory_backed_teams_in_dir(&base_dir) {
Ok(0) => {}
Ok(n) => eprintln!("buzz-desktop: detach-dir-teams: detached {n} directory-backed team(s)"),
Err(e) => eprintln!("buzz-desktop: detach-dir-teams: {e}"),
}
}
/// Core logic, decoupled from the Tauri `AppHandle` for testing.
///
/// `base_dir` is the managed-agents base directory (`<AppDataDir>/agents/`).
-32
View File
@@ -3,38 +3,6 @@
use std::path::Path;
/// Fold `personas.json` into the unified agent store (Phase 1A.2).
///
/// One-way, versioned by presence: runs only while `personas.json` exists.
/// Each persona becomes a key-less definition record
/// ([`AgentDefinition::into_agent_record`]) appended to `managed-agents.json`
/// via the definition-preserving save; the old file is renamed to
/// `personas.json.bak` so a second boot is a no-op and the data survives for
/// manual recovery. Built-ins are skipped — `merge_personas` regenerates them
/// from code on every load, exactly as before.
///
/// Ordering (see `run_boot_migrations`): runs after the JSON-level
/// `personas.json` migrations (which must see the legacy file) and BEFORE
/// every consumer of the `load/save_personas` shims — `sync_team_personas`,
/// `reconcile_provider_mcp_commands`, and `materialize_agent_runtimes` all
/// read definitions post-fold via [`load_persona_runtimes`]'s unified-store
/// branch.
#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses fold_personas_in_dir.
pub fn fold_personas_into_agent_store(app: &tauri::AppHandle) {
let Ok(base_dir) = crate::managed_agents::managed_agents_base_dir(app) else {
return;
};
match fold_personas_in_dir(&base_dir) {
Ok(None) => {}
Ok(Some(folded)) => {
eprintln!(
"buzz-desktop: persona-store-fold: {folded} definitions folded into the unified store"
);
}
Err(e) => eprintln!("buzz-desktop: persona-store-fold: {e}"),
}
}
/// Core fold logic, decoupled from the Tauri `AppHandle` for testing.
/// Operates on the raw JSON files — no keyring interaction: instance records
/// are passed through byte-identical, and folded definitions carry no keys.
+8 -33
View File
@@ -1,15 +1,9 @@
//! Phase 1A (unified agent model): boot-time materialization of each
//! persona-linked agent record's `runtime` onto the record itself.
//!
//! Child module of `migration` so it reuses the parent's private JSON-patch
//! helpers (`patch_json_records`, `load_persona_runtimes`,
//! `canonical_dev_data_dir`).
use std::path::Path;
use tauri::Manager as _;
use super::{canonical_dev_data_dir, load_persona_runtimes, patch_json_records};
use super::{load_persona_runtimes, patch_json_records};
/// Materialize each persona-linked agent record's `runtime` from its linked
/// persona (unified agent model, Phase 1A). After this, spawn resolution reads
@@ -21,29 +15,10 @@ use super::{canonical_dev_data_dir, load_persona_runtimes, patch_json_records};
/// Idempotent: records that already carry `runtime` are untouched, as are
/// records with no linked persona or a persona without a runtime (both keep
/// resolving through the legacy fallback path unchanged).
#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses materialize_agent_runtimes_at.
pub fn materialize_agent_runtimes(app: &tauri::AppHandle) {
let Ok(current_dir) = app.path().app_data_dir() else {
return;
};
let mut dirs = vec![current_dir.clone()];
if let Some(canonical) = canonical_dev_data_dir(&current_dir) {
if canonical.exists() && canonical != current_dir {
dirs.push(canonical);
}
}
for dir in dirs {
let path = dir.join("agents/managed-agents.json");
if path.exists() {
materialize_runtimes_in_file(&path);
}
}
}
pub(crate) fn materialize_runtimes_in_file(path: &Path) {
pub(crate) fn materialize_runtimes_in_file(path: &Path) -> Result<(), String> {
let persona_runtimes = load_persona_runtimes(path);
if persona_runtimes.is_empty() {
return;
return Ok(());
}
patch_json_records(path, |obj| {
if obj.contains_key("runtime") {
@@ -61,7 +36,7 @@ pub(crate) fn materialize_runtimes_in_file(path: &Path) {
serde_json::Value::String(runtime.clone()),
);
true
});
})
}
#[cfg(test)]
@@ -82,7 +57,7 @@ mod tests {
dir.path(),
&serde_json::json!([{ "name": "Fizz", "persona_id": "persona-1" }]),
);
materialize_runtimes_in_file(&dir.path().join("agents/managed-agents.json"));
materialize_runtimes_in_file(&dir.path().join("agents/managed-agents.json")).unwrap();
let records = read_agents_json(dir.path());
assert_eq!(records[0]["runtime"], "goose");
}
@@ -104,7 +79,7 @@ mod tests {
]),
);
let agents_path = dir.path().join("agents/managed-agents.json");
materialize_runtimes_in_file(&agents_path);
materialize_runtimes_in_file(&agents_path).unwrap();
let records = read_agents_json(dir.path());
assert_eq!(
records[0]["runtime"], "claude",
@@ -113,7 +88,7 @@ mod tests {
assert_eq!(records[1]["runtime"], "goose");
let before = std::fs::read_to_string(&agents_path).unwrap();
materialize_runtimes_in_file(&agents_path);
materialize_runtimes_in_file(&agents_path).unwrap();
let after = std::fs::read_to_string(&agents_path).unwrap();
assert_eq!(before, after, "second run must be a no-op");
}
@@ -134,7 +109,7 @@ mod tests {
);
let agents_path = dir.path().join("agents/managed-agents.json");
let before = std::fs::read_to_string(&agents_path).unwrap();
materialize_runtimes_in_file(&agents_path);
materialize_runtimes_in_file(&agents_path).unwrap();
let after = std::fs::read_to_string(&agents_path).unwrap();
assert_eq!(before, after, "no linked runtime → untouched file");
}
@@ -45,21 +45,6 @@ const TEAM_DELIMITER: &str = "\n\n---\n# Team Instructions\n";
/// `personas.json` are cleaned in the same boot, and BEFORE
/// `backfill_standalone_agents` so a manufactured definition never snapshots
/// a suffix this migration is about to remove.
#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses strip_baked_team_instructions_in_dir.
pub fn strip_baked_team_instructions(app: &tauri::AppHandle) {
let Ok(base_dir) = crate::managed_agents::managed_agents_base_dir(app) else {
return;
};
match strip_baked_team_instructions_in_dir(&base_dir) {
Ok(0) => {}
Ok(stripped) => eprintln!(
"buzz-desktop: team-suffix-strip: removed the baked team-instructions suffix from \
{stripped} record(s)"
),
Err(e) => eprintln!("buzz-desktop: team-suffix-strip: {e}"),
}
}
/// Core logic, decoupled from the Tauri `AppHandle` for testing.
///
/// `base_dir` is the managed-agents base directory (`<AppDataDir>/agents/`).
@@ -82,104 +82,8 @@ fn reconcile_legacy_command_names_preserves_custom_commands() {
assert_eq!(before, std::fs::read_to_string(&path).unwrap());
}
#[test]
fn reconcile_legacy_command_names_rewrites_persona_runtime() {
let dir = tempfile::tempdir().unwrap();
write_personas_json(
dir.path(),
&serde_json::json!([{
"id": "persona-1",
"display_name": "Brain",
"runtime": "sprout-agent"
}]),
);
reconcile_legacy_persona_runtimes_in_file(&dir.path().join("agents/personas.json"));
let records = read_personas_json(dir.path());
assert_eq!(records[0]["runtime"], "buzz-agent");
}
#[test]
fn reconcile_legacy_command_names_rewrites_runtime_after_provider_migration() {
let dir = tempfile::tempdir().unwrap();
write_personas_json(
dir.path(),
&serde_json::json!([{
"id": "persona-1",
"display_name": "Brain",
"provider": "sprout-agent"
}]),
);
let path = dir.path().join("agents/personas.json");
rename_provider_to_runtime_in_personas(&path);
reconcile_legacy_persona_runtimes_in_file(&path);
let records = read_personas_json(dir.path());
assert_eq!(records[0]["runtime"], "buzz-agent");
assert!(records[0].get("provider").is_none());
}
#[test]
fn reconcile_legacy_command_names_preserves_non_legacy_persona_runtime() {
let dir = tempfile::tempdir().unwrap();
write_personas_json(
dir.path(),
&serde_json::json!([{
"id": "persona-1",
"display_name": "Solo",
"runtime": "goose"
}]),
);
let path = dir.path().join("agents/personas.json");
let before = std::fs::read_to_string(&path).unwrap();
reconcile_legacy_persona_runtimes_in_file(&path);
assert_eq!(before, std::fs::read_to_string(&path).unwrap());
}
#[test]
fn rewrite_legacy_persona_md_runtime_rewrites_frontmatter_only() {
let content = concat!(
"---\n",
"name: brain\n",
"display_name: Brain\n",
"description: Test persona\n",
"runtime: sprout-agent\n",
"---\n",
"Body mentions runtime: sprout-agent.\n",
);
let updated = rewrite_legacy_persona_md_runtime(content).unwrap();
assert!(updated.contains("runtime: buzz-agent\n"));
assert!(updated.contains("Body mentions runtime: sprout-agent.\n"));
}
#[test]
fn reconcile_legacy_team_persona_runtime_files_rewrites_persona_md() {
let dir = tempfile::tempdir().unwrap();
let teams_dir = dir.path().join("agents/teams/com.example.team/agents");
std::fs::create_dir_all(&teams_dir).unwrap();
let persona_path = teams_dir.join("brain.persona.md");
std::fs::write(
&persona_path,
concat!(
"---\n",
"name: brain\n",
"display_name: Brain\n",
"description: Test persona\n",
"runtime: sprout-agent\n",
"---\n",
"Prompt\n",
),
)
.unwrap();
reconcile_legacy_team_persona_runtime_files(&dir.path().join("agents/teams"));
let updated = std::fs::read_to_string(persona_path).unwrap();
assert!(updated.contains("runtime: buzz-agent\n"));
}
// Tests for `reconcile_legacy_persona_runtimes_in_file`,
// `rewrite_legacy_persona_md_runtime`, and
// `reconcile_legacy_team_persona_runtime_files` were removed alongside those
// deleted functions. The scoped pipeline's `_in_dir` variants carry the
// equivalent coverage.
+15 -10
View File
@@ -5,15 +5,16 @@ pub(crate) use materialize::materialize_runtimes_in_file;
pub(crate) use team_suffix::strip_baked_team_instructions_in_dir;
/// Reconcile `mcp_command` values in a scoped `definitions_dir`.
pub(crate) fn reconcile_provider_mcp_commands_at(definitions_dir: &std::path::Path) {
pub(crate) fn reconcile_provider_mcp_commands_at(definitions_dir: &std::path::Path) -> Result<(), String> {
let path = definitions_dir.join("managed-agents.json");
if path.exists() {
reconcile_mcp_commands_in_file(&path);
reconcile_mcp_commands_in_file(&path)?;
}
Ok(())
}
/// Reconcile Databricks V1 → V2 provider entries in a scoped `definitions_dir`.
pub(crate) fn reconcile_databricks_v1_to_v2_at(definitions_dir: &std::path::Path) {
pub(crate) fn reconcile_databricks_v1_to_v2_at(definitions_dir: &std::path::Path) -> Result<(), String> {
use crate::managed_agents::baked_build_env;
let rewrite_v1_provider = baked_build_env()
.get("BUZZ_AGENT_PROVIDER")
@@ -21,30 +22,34 @@ pub(crate) fn reconcile_databricks_v1_to_v2_at(definitions_dir: &std::path::Path
.unwrap_or(false);
let path = definitions_dir.join("managed-agents.json");
if path.exists() {
reconcile_databricks_v1_to_v2_in_file(&path, rewrite_v1_provider);
reconcile_databricks_v1_to_v2_in_file(&path, rewrite_v1_provider)?;
}
Ok(())
}
/// Refresh legacy built-in agent avatars in a scoped `definitions_dir`.
pub(crate) fn refresh_builtin_agent_avatars_at(definitions_dir: &std::path::Path) {
pub(crate) fn refresh_builtin_agent_avatars_at(definitions_dir: &std::path::Path) -> Result<(), String> {
let path = definitions_dir.join("managed-agents.json");
if path.exists() {
refresh_builtin_agent_avatars_in_file(&path, LEGACY_BUILTIN_AVATARS, &crate::util::now_iso());
refresh_builtin_agent_avatars_in_file(&path, LEGACY_BUILTIN_AVATARS, &crate::util::now_iso())?;
}
Ok(())
}
/// Reconcile legacy command names in a scoped `definitions_dir`.
pub(crate) fn reconcile_legacy_command_names_at(definitions_dir: &std::path::Path) {
pub(crate) fn reconcile_legacy_command_names_at(definitions_dir: &std::path::Path) -> Result<(), String> {
let path = definitions_dir.join("managed-agents.json");
if path.exists() {
reconcile_legacy_command_names_in_file(&path);
reconcile_legacy_command_names_in_file(&path)?;
}
Ok(())
}
/// Materialize per-record runtimes in a scoped `definitions_dir`.
pub(crate) fn materialize_agent_runtimes_at(definitions_dir: &std::path::Path) {
pub(crate) fn materialize_agent_runtimes_at(definitions_dir: &std::path::Path) -> Result<(), String> {
let path = definitions_dir.join("managed-agents.json");
if path.exists() {
materialize_runtimes_in_file(&path);
materialize_runtimes_in_file(&path)?;
}
Ok(())
}