Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions crates/buzz-db/src/partition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,13 @@ async fn ensure_partition(
Ok(())
}
Err(sqlx::Error::Database(db_err))
if db_err.code().as_deref() == Some("42P17")
&& db_err.message().contains("would overlap partition") =>
if db_err.message().contains("would overlap partition") =>
{
// Fresh schemas include a right-edge catch-all partition (`*_p_future`).
// If it already covers this month, the table is still safe for writes;
// treat the overlap as "ensured" rather than failing startup.
// Match on the message alone: some Postgres/sqlx paths omit SQLSTATE
// `42P17`, and a missed match was logging ERROR on every boot (#2474).
info!(
partition_name,
"partition range already covered by an existing partition"
Expand Down
2 changes: 2 additions & 0 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ const overrides = new Map([
// 3-phase (stage/stop/commit) + commit_cascade_agents injectable helper for
// retry-safety. Load-bearing reviewer-required change; queued to split.
// Consolidation removed the legacy persona-card import/export codecs.
// respond-to gate propagation (+ linked-instance write) lives in propagate.rs;
// mod.rs stays under the ratchet after the helper split.
["src-tauri/src/commands/personas/mod.rs", 984],
// #1418 read-path fix: get_thread_replies' blocker fix (shared TIMELINE_KINDS
// const + build_thread_replies_filter helper, mirroring the channel sibling so
Expand Down
46 changes: 17 additions & 29 deletions desktop/src-tauri/src/commands/personas/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ use crate::{
util::now_iso,
};

mod propagate;
use propagate::{propagate_persona_name_rename, propagate_persona_respond_to};

fn trim_required(value: &str, label: &str) -> Result<String, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
Expand Down Expand Up @@ -117,32 +120,6 @@ pub struct UpdatePersonaResult {
persona: AgentDefinition,
}

/// Propagate a persona definition's display_name rename to linked agent instances.
/// Only instances whose current `name` equals `old_display_name` are updated;
/// pool-named instances (e.g. "Birch", "Compass") keep their individualised name.
/// Updates both `record.name` (relay display name) and `record.display_name`.
/// Returns the pubkeys of the records that were renamed.
fn propagate_persona_name_rename(
records: &mut [ManagedAgentRecord],
persona_id: &str,
old_display_name: &str,
new_display_name: &str,
) -> Vec<String> {
let mut renamed = Vec::new();
for record in records.iter_mut() {
if record.persona_id.as_deref() != Some(persona_id) {
continue;
}
if record.name != old_display_name {
continue; // pool-named instance — keep its individualised name
}
record.name = new_display_name.to_string();
record.display_name = Some(new_display_name.to_string());
renamed.push(record.pubkey.clone());
}
renamed
}

#[tauri::command]
pub async fn update_persona(
input: UpdatePersonaRequest,
Expand Down Expand Up @@ -196,6 +173,7 @@ pub async fn update_persona(
crate::managed_agents::validate_user_env_keys(&env_vars)?;
persona.env_vars = env_vars;
}
let behavior_present = input.behavior.is_some();
apply_persona_behavior(persona, input.behavior)?;
persona.updated_at = now_iso();

Expand All @@ -205,9 +183,11 @@ pub async fn update_persona(
retain_persona_pending(&app, &state, &result);
try_regenerate_nest(&app);

// If the avatar or display_name changed, propagate to linked agent
// records and collect relay profile sync params for the async phase.
let sync_params: ProfileSyncParams = if avatar_changed || name_changed {
// Propagate definition edits that instances must mirror (avatar,
// display name, respond-to gate) and collect relay profile sync
// params for the async phase.
let sync_params: ProfileSyncParams =
if avatar_changed || name_changed || behavior_present {
let mut records = load_managed_agents(&app)?;
let mut params: ProfileSyncParams = Vec::new();
let mut agents_modified = false;
Expand All @@ -228,6 +208,12 @@ pub async fn update_persona(
Vec::new()
};

if behavior_present
&& propagate_persona_respond_to(&mut records, &result.id, &result)? > 0
{
agents_modified = true;
}

for record in records.iter_mut() {
if record.persona_id.as_deref() != Some(&result.id) {
continue;
Expand Down Expand Up @@ -316,6 +302,8 @@ mod delete_cascade_tests;
mod inbound_tests;
#[cfg(test)]
mod name_propagation_tests;
#[cfg(test)]
mod respond_to_propagation_tests;

/// Return pubkeys of every managed agent whose definition is the given persona.
///
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
//! Tests for `propagate_persona_name_rename` — the helper that propagates a
//! persona definition's display_name change to linked agent instances.

use super::*;
use super::propagate::propagate_persona_name_rename;
use crate::managed_agents::ManagedAgentRecord;

fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAgentRecord {
ManagedAgentRecord {
Expand Down
76 changes: 76 additions & 0 deletions desktop/src-tauri/src/commands/personas/propagate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//! Shared persona → instance field propagation helpers.

use crate::managed_agents::{
validate_respond_to_allowlist, AgentDefinition, ManagedAgentRecord, RespondTo,
};

/// Propagate a persona definition's display_name rename to linked agent instances.
/// Only instances whose current `name` equals `old_display_name` are updated;
/// pool-named instances (e.g. "Birch", "Compass") keep their individualised name.
/// Updates both `record.name` (relay display name) and `record.display_name`.
/// Returns the pubkeys of the records that were renamed.
pub(super) fn propagate_persona_name_rename(
records: &mut [ManagedAgentRecord],
persona_id: &str,
old_display_name: &str,
new_display_name: &str,
) -> Vec<String> {
let mut renamed = Vec::new();
for record in records.iter_mut() {
if record.persona_id.as_deref() != Some(persona_id) {
continue;
}
if record.name != old_display_name {
continue; // pool-named instance — keep its individualised name
}
record.name = new_display_name.to_string();
record.display_name = Some(new_display_name.to_string());
renamed.push(record.pubkey.clone());
}
renamed
}

/// Propagate a definition's respond-to gate onto linked running instances.
///
/// Definition edits write `definition_respond_to` only; the harness reads each
/// instance's `respond_to`. Without this, "Who can talk to this agent" on a
/// definition never reaches buzz-acp.
///
/// Allowlist entries are replaced only when the definition mode is allowlist
/// (mirrors `update_managed_agent` preserve-across-toggle semantics).
pub(super) fn propagate_persona_respond_to(
records: &mut [ManagedAgentRecord],
persona_id: &str,
definition: &AgentDefinition,
) -> Result<usize, String> {
let mode = match definition.respond_to.as_deref() {
Some(wire) => RespondTo::parse_wire(wire)?,
None => RespondTo::default(),
};
let allowlist = if mode == RespondTo::Allowlist {
validate_respond_to_allowlist(&definition.respond_to_allowlist)?
} else {
Vec::new()
};

let mut updated = 0;
for record in records.iter_mut() {
if record.persona_id.as_deref() != Some(persona_id) {
continue;
}
if record.pubkey.is_empty() {
continue;
}

let mut changed = record.respond_to != mode;
record.respond_to = mode;
if mode == RespondTo::Allowlist && record.respond_to_allowlist != allowlist {
record.respond_to_allowlist = allowlist.clone();
changed = true;
}
if changed {
updated += 1;
}
}
Ok(updated)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
//! Tests for `propagate_persona_respond_to` — definition respond-to edits must
//! reach linked instance records the harness actually reads.

use super::propagate::propagate_persona_respond_to;
use crate::managed_agents::{AgentDefinition, ManagedAgentRecord, RespondTo};

fn agent(persona_id: &str, name: &str) -> ManagedAgentRecord {
ManagedAgentRecord {
pubkey: format!("pubkey-{name}"),
name: name.to_string(),
persona_id: Some(persona_id.to_string()),
private_key_nsec: String::new(),
auth_tag: None,
relay_url: String::new(),
avatar_url: None,
acp_command: String::new(),
agent_command: String::new(),
agent_command_override: None,
agent_args: vec![],
mcp_command: String::new(),
turn_timeout_seconds: 0,
idle_timeout_seconds: None,
max_turn_duration_seconds: None,
parallelism: 1,
system_prompt: None,
model: None,
provider: None,
persona_source_version: None,
env_vars: std::collections::BTreeMap::new(),
start_on_app_launch: false,
auto_restart_on_config_change: true,
runtime_pid: None,
backend: Default::default(),
backend_agent_id: None,
provider_binary_path: None,
team_id: None,
persona_team_dir: None,
persona_name_in_team: None,
created_at: String::new(),
updated_at: String::new(),
last_started_at: None,
last_stopped_at: None,
last_exit_code: None,
last_error: None,
last_error_code: None,
respond_to: RespondTo::OwnerOnly,
respond_to_allowlist: vec![],
display_name: Some(name.to_string()),
slug: None,
runtime: None,
name_pool: vec![],
is_builtin: false,
is_active: true,
source_team: None,
source_team_persona_slug: None,
definition_respond_to: None,
definition_respond_to_allowlist: vec![],
definition_parallelism: None,
relay_mesh: None,
}
}

fn definition(respond_to: Option<&str>, allowlist: Vec<&str>) -> AgentDefinition {
AgentDefinition {
id: "persona-1".to_string(),
display_name: "Scout".to_string(),
avatar_url: None,
system_prompt: String::new(),
runtime: None,
model: None,
provider: None,
name_pool: vec![],
is_builtin: false,
is_active: true,
source_team: None,
source_team_persona_slug: None,
env_vars: Default::default(),
respond_to: respond_to.map(str::to_string),
respond_to_allowlist: allowlist.into_iter().map(str::to_string).collect(),
parallelism: None,
created_at: String::new(),
updated_at: String::new(),
}
}

#[test]
fn respond_to_anyone_propagates_to_linked_instances() {
let mut records = vec![agent("persona-1", "Scout"), agent("persona-2", "Other")];
let updated = propagate_persona_respond_to(
&mut records,
"persona-1",
&definition(Some("anyone"), vec![]),
)
.expect("propagate");

assert_eq!(updated, 1);
assert_eq!(records[0].respond_to, RespondTo::Anyone);
assert!(records[0].respond_to_allowlist.is_empty());
assert_eq!(records[1].respond_to, RespondTo::OwnerOnly);
}

#[test]
fn respond_to_allowlist_propagates_mode_and_entries() {
let allow = "a".repeat(64);
let mut records = vec![agent("persona-1", "Scout")];
let updated = propagate_persona_respond_to(
&mut records,
"persona-1",
&definition(Some("allowlist"), vec![&allow]),
)
.expect("propagate");

assert_eq!(updated, 1);
assert_eq!(records[0].respond_to, RespondTo::Allowlist);
assert_eq!(records[0].respond_to_allowlist, vec![allow]);
}

#[test]
fn respond_to_non_allowlist_preserves_instance_allowlist_entries() {
let existing = "b".repeat(64);
let mut records = vec![agent("persona-1", "Scout")];
records[0].respond_to = RespondTo::Allowlist;
records[0].respond_to_allowlist = vec![existing.clone()];

let updated = propagate_persona_respond_to(
&mut records,
"persona-1",
&definition(Some("anyone"), vec![]),
)
.expect("propagate");

assert_eq!(updated, 1);
assert_eq!(records[0].respond_to, RespondTo::Anyone);
assert_eq!(records[0].respond_to_allowlist, vec![existing]);
}

#[test]
fn respond_to_skips_empty_pubkey_definition_rows() {
let mut records = vec![agent("persona-1", "Scout")];
records[0].pubkey.clear();
let updated = propagate_persona_respond_to(
&mut records,
"persona-1",
&definition(Some("anyone"), vec![]),
)
.expect("propagate");
assert_eq!(updated, 0);
}
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,13 @@ export async function buildInstanceInputForDefinition(
harnessOverride: !persona.runtime || persona.runtime === runtime.id,
model: persona.model ?? undefined,
provider: persona.provider ?? undefined,
// Pass definition gate explicitly so create/start doesn't depend solely on
// the mint-time linked-persona lookup (still inherited there as fallback).
respondTo: persona.respondTo ?? undefined,
respondToAllowlist:
persona.respondTo === "allowlist"
? [...persona.respondToAllowlist]
: undefined,
spawnAfterCreate: true,
startOnAppLaunch: true,
backend: { type: "local" },
Expand Down
Loading