From 97b083d9817169377d1ec034332d74fafc7e786b Mon Sep 17 00:00:00 2001 From: Taksh Date: Thu, 23 Jul 2026 15:04:29 +0300 Subject: [PATCH 1/2] fix(desktop): propagate definition respond-to onto agent instances Who-can-talk edits on a persona only wrote definition_respond_to; the harness reads instance respond_to, so linked agents stayed owner-only. Signed-off-by: Taksh Co-authored-by: Cursor --- desktop/scripts/check-file-sizes.mjs | 2 + .../src-tauri/src/commands/personas/mod.rs | 46 ++---- .../personas/name_propagation_tests.rs | 3 +- .../src/commands/personas/propagate.rs | 76 +++++++++ .../personas/respond_to_propagation_tests.rs | 148 ++++++++++++++++++ .../agents/lib/instanceInputForDefinition.ts | 7 + .../profile/ui/UserProfilePanelUtils.test.mjs | 38 +++++ .../profile/ui/UserProfilePanelUtils.ts | 13 ++ 8 files changed, 303 insertions(+), 30 deletions(-) create mode 100644 desktop/src-tauri/src/commands/personas/propagate.rs create mode 100644 desktop/src-tauri/src/commands/personas/respond_to_propagation_tests.rs diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index c57e0e982e..d83c8ee07e 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -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 diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 6437d4b1a8..0b9fedf8bc 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -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 { let trimmed = value.trim(); if trimmed.is_empty() { @@ -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 { - 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, @@ -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(); @@ -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; @@ -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; @@ -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. /// diff --git a/desktop/src-tauri/src/commands/personas/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/name_propagation_tests.rs index ba855ccbd6..4cef70a176 100644 --- a/desktop/src-tauri/src/commands/personas/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/name_propagation_tests.rs @@ -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 { diff --git a/desktop/src-tauri/src/commands/personas/propagate.rs b/desktop/src-tauri/src/commands/personas/propagate.rs new file mode 100644 index 0000000000..de71007de9 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/propagate.rs @@ -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 { + 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 { + 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) +} diff --git a/desktop/src-tauri/src/commands/personas/respond_to_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/respond_to_propagation_tests.rs new file mode 100644 index 0000000000..72e8e90cb9 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/respond_to_propagation_tests.rs @@ -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); +} diff --git a/desktop/src/features/agents/lib/instanceInputForDefinition.ts b/desktop/src/features/agents/lib/instanceInputForDefinition.ts index 7db36b7241..59173544bb 100644 --- a/desktop/src/features/agents/lib/instanceInputForDefinition.ts +++ b/desktop/src/features/agents/lib/instanceInputForDefinition.ts @@ -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" }, diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs index 89837f6017..03eaefd52b 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs @@ -92,6 +92,44 @@ test("personaManagedAgentUpdate syncs edited persona identity to linked agent", }); }); +test("personaManagedAgentUpdate syncs respondTo gate to linked agent", () => { + assert.deepEqual( + personaManagedAgentUpdate( + agent(), + persona({ respondTo: "anyone", respondToAllowlist: [] }), + ), + { + pubkey: "deadbeef".repeat(8), + name: "Fizz Prime", + systemPrompt: "New prompt", + model: "new-model", + envVars: { NEW_KEY: "2" }, + respondTo: "anyone", + }, + ); +}); + +test("personaManagedAgentUpdate syncs allowlist when mode is allowlist", () => { + const allow = "a".repeat(64); + assert.deepEqual( + personaManagedAgentUpdate( + agent({ respondTo: "allowlist", respondToAllowlist: [] }), + persona({ + displayName: "Fizz", + systemPrompt: "Old prompt", + model: "old-model", + envVars: { OLD_KEY: "1" }, + respondTo: "allowlist", + respondToAllowlist: [allow], + }), + ), + { + pubkey: "deadbeef".repeat(8), + respondToAllowlist: [allow], + }, + ); +}); + test("personaManagedAgentUpdate skips unrelated or unchanged agents", () => { assert.equal( personaManagedAgentUpdate(agent({ personaId: "persona-2" }), persona()), diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index 07f57803b4..2f3068b55b 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -292,6 +292,19 @@ export function personaManagedAgentUpdate( hasChanges = true; } + const personaRespondTo = persona.respondTo ?? "owner-only"; + if (personaRespondTo !== agent.respondTo) { + input.respondTo = personaRespondTo; + hasChanges = true; + } + if ( + personaRespondTo === "allowlist" && + persona.respondToAllowlist.join(",") !== agent.respondToAllowlist.join(",") + ) { + input.respondToAllowlist = [...persona.respondToAllowlist]; + hasChanges = true; + } + const runtimeChanged = options.previousPersona !== undefined && options.previousPersona.runtime !== persona.runtime; From 47be6e9b03b42913edeedab5a7ecae00568fdca7 Mon Sep 17 00:00:00 2001 From: Taksh Date: Thu, 23 Jul 2026 15:04:29 +0300 Subject: [PATCH 2/2] fix(db): treat any partition overlap as already ensured Match on the overlap message alone so missing SQLSTATE 42P17 no longer logs ERROR on every boot against *_p_future. Signed-off-by: Taksh Co-authored-by: Cursor --- crates/buzz-db/src/partition.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/buzz-db/src/partition.rs b/crates/buzz-db/src/partition.rs index b3803f1b34..1dc3382092 100644 --- a/crates/buzz-db/src/partition.rs +++ b/crates/buzz-db/src/partition.rs @@ -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"