From 7956e34251bef18d3fa83708022b4a9d5ae19076 Mon Sep 17 00:00:00 2001
From: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n
<03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co>
Date: Thu, 16 Jul 2026 11:01:26 -0700
Subject: [PATCH 01/11] feat(acp): add conversation-scoped session experiment
Co-authored-by: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n <03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1k766u8wq088nkr9vy94uqlh9kmw5956g593a7xk7cks7frhgctmsr00p27
Signed-off-by: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n <03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1k766u8wq088nkr9vy94uqlh9kmw5956g593a7xk7cks7frhgctmsr00p27
---
crates/buzz-acp/src/config.rs | 9 +
crates/buzz-acp/src/lib.rs | 211 ++++-
crates/buzz-acp/src/pool.rs | 800 +++++++++++++++---
crates/buzz-acp/src/queue.rs | 278 +++++-
crates/buzz-acp/src/relay.rs | 8 +
desktop/src-tauri/src/app_state.rs | 3 +
desktop/src-tauri/src/commands/experiments.rs | 121 +++
desktop/src-tauri/src/commands/mod.rs | 2 +
desktop/src-tauri/src/lib.rs | 12 +
.../src-tauri/src/managed_agents/runtime.rs | 27 +
.../src/managed_agents/runtime/tests.rs | 47 +
.../settings/ui/ExperimentalFeaturesCard.tsx | 83 +-
.../ui/acpTopLevelSessionsExperiment.test.mjs | 133 +++
.../ui/acpTopLevelSessionsExperiment.ts | 66 ++
preview-features.json | 8 +
15 files changed, 1647 insertions(+), 161 deletions(-)
create mode 100644 desktop/src-tauri/src/commands/experiments.rs
create mode 100644 desktop/src/features/settings/ui/acpTopLevelSessionsExperiment.test.mjs
create mode 100644 desktop/src/features/settings/ui/acpTopLevelSessionsExperiment.ts
diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs
index befb7aa6aa..2bac14ead5 100644
--- a/crates/buzz-acp/src/config.rs
+++ b/crates/buzz-acp/src/config.rs
@@ -467,6 +467,11 @@ pub struct CliArgs {
/// Publish encrypted ACP observer frames over the relay.
#[arg(long, env = "BUZZ_ACP_RELAY_OBSERVER", default_value_t = false)]
pub relay_observer: bool,
+
+ /// Isolate managed-agent ACP sessions by human conversation root.
+ /// Disabled by default; Desktop enables it through its in-app experiment.
+ #[arg(long, env = "BUZZ_ACP_TOP_LEVEL_SESSIONS", default_value_t = false)]
+ pub top_level_sessions: bool,
}
/// Merged NIP-01 subscription filter for a single channel.
@@ -537,6 +542,8 @@ pub struct Config {
pub has_generated_codex_config: bool,
/// Whether to publish encrypted observer frames through the relay.
pub relay_observer: bool,
+ /// Whether channel turns use conversation-root-scoped ACP sessions.
+ pub top_level_sessions: bool,
/// Agent owner pubkey (hex). Used for `--respond-to=owner-only` gate.
/// Replaces the old REST-based owner lookup.
pub agent_owner: Option,
@@ -996,6 +1003,7 @@ impl Config {
persona_env_vars,
has_generated_codex_config,
relay_observer: args.relay_observer,
+ top_level_sessions: args.top_level_sessions,
agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()),
no_base_prompt: args.no_base_prompt,
base_prompt_content,
@@ -1369,6 +1377,7 @@ mod tests {
has_generated_codex_config: false,
relay_observer: false,
agent_owner: None,
+ top_level_sessions: false,
no_base_prompt: false,
base_prompt_content: None,
}
diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs
index 4d155e2189..7f94ede7bb 100644
--- a/crates/buzz-acp/src/lib.rs
+++ b/crates/buzz-acp/src/lib.rs
@@ -815,6 +815,7 @@ fn handle_switch_model_control(
channel_id,
ControlSignal::SwitchModel(model_id.to_string()),
) {
+ pool.switch_other_idle_channel_roots(channel_id, model_id);
"sent"
} else {
"turn_ending"
@@ -1455,7 +1456,7 @@ async fn tokio_main() -> Result<()> {
base_prompt: if config.no_base_prompt {
None
} else if let Some(content) = base_prompt_content {
- Some(Box::leak(content.into_boxed_str()))
+ Some(Box::leak(content.into_boxed_str()) as &str)
} else {
Some(include_str!("base_prompt.md"))
},
@@ -1474,6 +1475,7 @@ async fn tokio_main() -> Result<()> {
.as_deref()
.and_then(|hex| nostr::PublicKey::from_hex(hex).ok()),
memory_enabled: config.memory_enabled,
+ top_level_sessions: config.top_level_sessions,
harness_name: crate::config::normalize_agent_command_identity(&config.agent_command),
});
@@ -1631,6 +1633,7 @@ async fn tokio_main() -> Result<()> {
if !slot.can_refill() {
continue;
}
+ pool.clear_slot_session_owners(idx);
slot.respawn_in_flight = true;
tracing::info!(agent = idx, "slot refill: spawning background respawn");
let cmd = config.agent_command.clone();
@@ -2030,6 +2033,12 @@ async fn tokio_main() -> Result<()> {
event: buzz_event.event,
received_at: std::time::Instant::now(),
prompt_tag,
+ conversation_root: if config.top_level_sessions {
+ let tags = queue::parse_thread_tags(&event_for_steer);
+ Some(tags.root_event_id.unwrap_or_else(|| event_id_hex.clone()))
+ } else {
+ None
+ },
});
// π β immediate "seen" reaction, only if the event
// was actually queued (not dropped by DedupMode::Drop).
@@ -2178,8 +2187,8 @@ async fn tokio_main() -> Result<()> {
match pool_event {
Some(PoolEvent::Result(result)) => {
// Stop typing indicator for the completed channel.
- if let PromptSource::Channel(ch) = &result.source {
- typing_channels.remove(ch);
+ if let PromptSource::Channel(key) = &result.source {
+ typing_channels.remove(&key.channel_id);
}
if handle_prompt_result(
&mut pool,
@@ -2661,8 +2670,9 @@ fn dispatch_pending(
.last()
.map(|event| queue::parse_thread_tags(&event.event))
.unwrap_or_default();
- let affinity_hit = pool.has_session_for(channel_id);
- let mut agent = match pool.try_claim(Some(channel_id)) {
+ let session_key = pool::conversation_session_key(&batch, ctx.top_level_sessions);
+ let affinity_hit = pool.has_session_for(&session_key);
+ let mut agent = match pool.try_claim(Some(&session_key)) {
Some(a) => a,
None => {
let pending = queue.pending_channels();
@@ -2855,7 +2865,7 @@ fn handle_prompt_result(
}
match &result.source {
- PromptSource::Channel(ch) => queue.mark_complete(*ch),
+ PromptSource::Channel(key) => queue.mark_complete(key.channel_id),
PromptSource::Heartbeat => *heartbeat_in_flight = false,
}
@@ -2891,7 +2901,7 @@ fn handle_prompt_result(
let harness_pid = std::process::id();
let channel_id = match &result.source {
- PromptSource::Channel(ch) => Some(*ch),
+ PromptSource::Channel(key) => Some(key.channel_id),
PromptSource::Heartbeat => None,
};
let turn_id = result.turn_id.clone();
@@ -2943,6 +2953,7 @@ fn handle_prompt_result(
emit_turn_error(&death_message, None);
let index = result.agent.index;
+ pool.clear_slot_session_owners(index);
let slot_history = &mut crash_history[index];
if !spawn_respawn_task(
result.agent,
@@ -2983,6 +2994,7 @@ fn handle_prompt_result(
emit_turn_error(&death_message, None);
let index = result.agent.index;
+ pool.clear_slot_session_owners(index);
let slot_history = &mut crash_history[index];
if !spawn_respawn_task(
result.agent,
@@ -3048,6 +3060,7 @@ fn handle_prompt_result(
emit_turn_error(&e.to_string(), error_code);
let index = result.agent.index;
+ pool.clear_slot_session_owners(index);
let slot_history = &mut crash_history[index];
if !spawn_respawn_task(
result.agent,
@@ -3099,6 +3112,7 @@ fn recover_panicked_agent(
return;
};
let i = meta.agent_index;
+ pool.clear_slot_session_owners(i);
// Requeue BEFORE mark_complete (same rationale as handle_prompt_result).
if let Some(batch) = meta.recoverable_batch {
@@ -4173,6 +4187,7 @@ mod build_mcp_servers_tests {
has_generated_codex_config: false,
relay_observer: false,
agent_owner: None,
+ top_level_sessions: false,
no_base_prompt: false,
base_prompt_content: None,
}
@@ -4338,6 +4353,7 @@ mod error_outcome_emission_tests {
has_generated_codex_config: false,
relay_observer: false,
agent_owner: None,
+ top_level_sessions: false,
no_base_prompt: false,
base_prompt_content: None,
}
@@ -4380,6 +4396,172 @@ mod error_outcome_emission_tests {
}
}
+ #[tokio::test]
+ async fn root_affinity_waits_for_busy_owner_instead_of_using_idle_slot() {
+ let first = dummy_agent(0).await;
+ let second = dummy_agent(1).await;
+ let mut pool = AgentPool::from_slots(vec![Some(first), Some(second)]);
+ let key = pool::ConversationSessionKey {
+ channel_id: Uuid::new_v4(),
+ root_event_id: Some("root-a".into()),
+ };
+
+ let mut owner = pool
+ .try_claim(Some(&key))
+ .expect("root should reserve slot 0");
+ assert_eq!(owner.index, 0);
+ let task_id = pool.join_set.spawn(async {}).id();
+ pool.task_map_mut().insert(
+ task_id,
+ crate::pool::TaskMeta {
+ agent_index: 0,
+ channel_id: Some(key.channel_id),
+ turn_id: "test-turn-id".to_string(),
+ recoverable_batch: None,
+ control_tx: None,
+ steer_tx: None,
+ },
+ );
+ assert!(pool.any_idle(), "slot 1 remains idle");
+ assert!(
+ pool.try_claim(Some(&key)).is_none(),
+ "reply must wait for the reserved root owner"
+ );
+ pool.task_map_mut().remove(&task_id);
+ owner.state.insert_session(key.clone(), "session-a".into());
+ pool.return_agent(owner);
+ assert_eq!(pool.try_claim(Some(&key)).unwrap().index, 0);
+ }
+
+ async fn pool_with_retained_root() -> (AgentPool, pool::ConversationSessionKey) {
+ let first = dummy_agent(0).await;
+ let second = dummy_agent(1).await;
+ let mut pool = AgentPool::from_slots(vec![Some(first), Some(second)]);
+ let key = pool::ConversationSessionKey {
+ channel_id: Uuid::new_v4(),
+ root_event_id: Some("root-a".into()),
+ };
+ let mut owner = pool.try_claim(Some(&key)).unwrap();
+ owner.state.insert_session(key.clone(), "session-a".into());
+ pool.return_agent(owner);
+ (pool, key)
+ }
+
+ #[tokio::test]
+ async fn root_owner_is_cleared_after_lru_eviction() {
+ let (mut pool, key) = pool_with_retained_root().await;
+ let mut owner = pool.try_claim(Some(&key)).unwrap();
+ for index in 0..=pool::SessionState::MAX_CHANNEL_SESSIONS {
+ owner.state.insert_session(
+ pool::ConversationSessionKey {
+ channel_id: key.channel_id,
+ root_event_id: Some(format!("new-root-{index}")),
+ },
+ format!("session-{index}"),
+ );
+ }
+ assert!(!owner.state.contains_session(&key));
+ pool.return_agent(owner);
+ pool.agents_mut()[0] = None;
+ assert_eq!(pool.try_claim(Some(&key)).unwrap().index, 1);
+ }
+
+ #[tokio::test]
+ async fn root_owner_is_cleared_by_channel_invalidation() {
+ let (mut pool, key) = pool_with_retained_root().await;
+ assert_eq!(pool.invalidate_channel_sessions(key.channel_id), 1);
+ pool.agents_mut()[0] = None;
+ assert_eq!(pool.try_claim(Some(&key)).unwrap().index, 1);
+ }
+
+ #[tokio::test]
+ async fn stale_dead_root_owner_falls_back_to_live_slot() {
+ let (mut pool, key) = pool_with_retained_root().await;
+ pool.agents_mut()[0] = None;
+ assert_eq!(pool.try_claim(Some(&key)).unwrap().index, 1);
+ }
+
+ #[tokio::test]
+ async fn dead_slot_cleanup_removes_all_root_reservations() {
+ let first = dummy_agent(0).await;
+ let second = dummy_agent(1).await;
+ let mut pool = AgentPool::from_slots(vec![Some(first), Some(second)]);
+ let channel_id = Uuid::new_v4();
+ let keys: Vec<_> = ["root-a", "root-b"]
+ .into_iter()
+ .map(|root| pool::ConversationSessionKey {
+ channel_id,
+ root_event_id: Some(root.into()),
+ })
+ .collect();
+ for key in &keys {
+ let mut owner = pool.try_claim(Some(key)).unwrap();
+ owner
+ .state
+ .insert_session(key.clone(), format!("session-{key:?}"));
+ pool.return_agent(owner);
+ }
+ pool.agents_mut()[0] = None;
+ assert_eq!(pool.clear_slot_session_owners(0), 2);
+ assert_eq!(pool.try_claim(Some(&keys[0])).unwrap().index, 1);
+ }
+
+ #[tokio::test]
+ async fn idle_model_switch_invalidates_all_roots_across_slots() {
+ let channel_id = Uuid::new_v4();
+ let key_a = pool::ConversationSessionKey {
+ channel_id,
+ root_event_id: Some("a".into()),
+ };
+ let key_b = pool::ConversationSessionKey {
+ channel_id,
+ root_event_id: Some("b".into()),
+ };
+ let mut first = dummy_agent(0).await;
+ first
+ .state
+ .insert_session(key_a.clone(), "session-a".into());
+ let mut second = dummy_agent(1).await;
+ second
+ .state
+ .insert_session(key_b.clone(), "session-b".into());
+ let mut pool = AgentPool::from_slots(vec![Some(first), Some(second)]);
+ assert_eq!(
+ pool.switch_idle_agent_model(channel_id, "new-model"),
+ IdleSwitchResult::Switched
+ );
+ for agent in pool.agents_mut().iter().flatten() {
+ assert!(!agent.state.has_channel_state(&channel_id));
+ assert_eq!(agent.desired_model.as_deref(), Some("new-model"));
+ }
+ }
+
+ #[tokio::test]
+ async fn busy_model_switch_invalidates_other_idle_roots() {
+ let channel_id = Uuid::new_v4();
+ let busy_key = pool::ConversationSessionKey {
+ channel_id,
+ root_event_id: Some("busy".into()),
+ };
+ let idle_key = pool::ConversationSessionKey {
+ channel_id,
+ root_event_id: Some("idle".into()),
+ };
+ let mut first = dummy_agent(0).await;
+ first
+ .state
+ .insert_session(busy_key.clone(), "session-busy".into());
+ let mut second = dummy_agent(1).await;
+ second.state.insert_session(idle_key, "session-idle".into());
+ let mut pool = AgentPool::from_slots(vec![Some(first), Some(second)]);
+ let busy = pool.try_claim(Some(&busy_key)).unwrap();
+ pool.switch_other_idle_channel_roots(channel_id, "new-model");
+ let idle = pool.agents_mut()[1].as_ref().unwrap();
+ assert!(!idle.state.has_channel_state(&channel_id));
+ assert_eq!(idle.desired_model.as_deref(), Some("new-model"));
+ pool.return_agent(busy);
+ }
+
/// Drive one error outcome through `handle_prompt_result` and return how
/// many `turn_error` events it emitted to the observer feed.
async fn turn_errors_emitted_for(outcome: PromptOutcome) -> usize {
@@ -4418,7 +4600,7 @@ mod error_outcome_emission_tests {
let result = PromptResult {
agent,
- source: PromptSource::Channel(Uuid::new_v4()),
+ source: PromptSource::Channel(pool::ConversationSessionKey::channel(Uuid::new_v4())),
turn_id: "test-turn-id".to_string(),
outcome,
batch: None,
@@ -4581,7 +4763,9 @@ mod error_outcome_emission_tests {
let observer = ObserverHandle::in_process();
let result = PromptResult {
agent,
- source: PromptSource::Channel(Uuid::new_v4()),
+ source: PromptSource::Channel(
+ pool::ConversationSessionKey::channel(Uuid::new_v4()),
+ ),
turn_id: "test-turn-id".to_string(),
outcome,
batch: None,
@@ -4624,6 +4808,7 @@ mod error_outcome_emission_tests {
.sign_with_keys(&keys)
.unwrap();
FlushBatch {
+ conversation_root: None,
channel_id: Uuid::new_v4(),
events: vec![BatchEvent {
event,
@@ -4665,7 +4850,7 @@ mod error_outcome_emission_tests {
let mut respawn_tasks = tokio::task::JoinSet::new();
let result = PromptResult {
agent,
- source: PromptSource::Channel(channel_id),
+ source: PromptSource::Channel(pool::ConversationSessionKey::channel(channel_id)),
turn_id: "test-turn-id".to_string(),
outcome,
batch: Some(batch),
@@ -4740,6 +4925,7 @@ mod error_outcome_emission_tests {
);
let channel_id = Uuid::new_v4();
let batch = FlushBatch {
+ conversation_root: None,
channel_id,
events: vec![BatchEvent {
event: original_event.clone(),
@@ -4770,6 +4956,7 @@ mod error_outcome_emission_tests {
// out on drain β so it is already queued by the time
// handle_prompt_result runs.
queue.push(QueuedEvent {
+ conversation_root: None,
channel_id,
event: new_event.clone(),
received_at: std::time::Instant::now(),
@@ -4789,7 +4976,7 @@ mod error_outcome_emission_tests {
let grace = std::time::Duration::from_secs(5);
let result = PromptResult {
agent,
- source: PromptSource::Channel(channel_id),
+ source: PromptSource::Channel(pool::ConversationSessionKey::channel(channel_id)),
turn_id: "test-turn-id".to_string(),
outcome: PromptOutcome::CancelDrainTimeout(grace),
batch: Some(batch),
@@ -4918,7 +5105,7 @@ mod error_outcome_emission_tests {
let grace = std::time::Duration::from_secs(5);
let result = PromptResult {
agent,
- source: PromptSource::Channel(Uuid::new_v4()),
+ source: PromptSource::Channel(pool::ConversationSessionKey::channel(Uuid::new_v4())),
turn_id: "test-turn-id".to_string(),
outcome: PromptOutcome::CancelDrainTimeout(grace),
// Explicit Stop already dropped the batch upstream in
diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs
index 3637869968..734b8c58cb 100644
--- a/crates/buzz-acp/src/pool.rs
+++ b/crates/buzz-acp/src/pool.rs
@@ -19,7 +19,7 @@
//!
//! `AcpClient` is NOT Clone β ownership moves out on claim and back on return.
-use std::collections::{HashMap, HashSet};
+use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -74,6 +74,22 @@ pub struct AgentModelCapabilities {
pub available_models_raw: Option,
}
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct ConversationSessionKey {
+ pub channel_id: Uuid,
+ pub root_event_id: Option,
+}
+
+impl ConversationSessionKey {
+ #[cfg(test)]
+ pub fn channel(channel_id: Uuid) -> Self {
+ Self {
+ channel_id,
+ root_event_id: None,
+ }
+ }
+}
+
/// Per-channel session IDs and turn counters.
///
/// Separated from `OwnedAgent` so the state machine is testable without
@@ -81,11 +97,13 @@ pub struct AgentModelCapabilities {
#[derive(Default)]
pub struct SessionState {
/// channel_id β session_id
- pub sessions: HashMap,
+ pub sessions: HashMap,
+ /// Least-recently-used order for bounded conversation-root sessions.
+ session_lru: VecDeque,
pub heartbeat_session: Option,
/// Per-channel turn counters for proactive session rotation.
/// Incremented on each successful prompt; reset when the session is rotated.
- pub turn_counts: HashMap,
+ pub turn_counts: HashMap,
/// Turn counter for the heartbeat session.
pub heartbeat_turn_count: u32,
/// channel_id β rendered NIP-AE core prompt section, populated once at
@@ -101,11 +119,62 @@ pub struct SessionState {
}
impl SessionState {
+ pub(crate) const MAX_CHANNEL_SESSIONS: usize = 32;
+
+ fn get_session(&mut self, key: &ConversationSessionKey) -> Option {
+ let session = self.sessions.get(key)?.clone();
+ self.touch(key);
+ Some(session)
+ }
+
+ pub(crate) fn contains_session(&self, key: &ConversationSessionKey) -> bool {
+ self.sessions.contains_key(key)
+ }
+
+ pub(crate) fn insert_session(&mut self, key: ConversationSessionKey, session_id: String) {
+ self.sessions.insert(key.clone(), session_id);
+ self.touch(&key);
+ while self
+ .sessions
+ .keys()
+ .filter(|candidate| candidate.root_event_id.is_some())
+ .count()
+ > Self::MAX_CHANNEL_SESSIONS
+ {
+ let evicted = self
+ .session_lru
+ .iter()
+ .position(|candidate| candidate.root_event_id.is_some())
+ .and_then(|index| self.session_lru.remove(index));
+ if let Some(evicted) = evicted {
+ self.sessions.remove(&evicted);
+ self.turn_counts.remove(&evicted);
+ } else {
+ break;
+ }
+ }
+ }
+
+ fn touch(&mut self, key: &ConversationSessionKey) {
+ self.session_lru.retain(|candidate| candidate != key);
+ self.session_lru.push_back(key.clone());
+ }
+
/// Invalidate the session (and turn counter) for a specific prompt source.
pub fn invalidate(&mut self, source: &PromptSource) {
match source {
- PromptSource::Channel(cid) => {
- self.invalidate_channel(cid);
+ PromptSource::Channel(key) => {
+ self.turn_counts.remove(key);
+ self.sessions.remove(key);
+ self.session_lru.retain(|candidate| candidate != key);
+ if !self
+ .sessions
+ .keys()
+ .any(|candidate| candidate.channel_id == key.channel_id)
+ {
+ self.core_sections.remove(&key.channel_id);
+ self.canvas_sections.remove(&key.channel_id);
+ }
}
PromptSource::Heartbeat => {
self.heartbeat_session = None;
@@ -114,18 +183,24 @@ impl SessionState {
}
}
- /// Invalidate a single channel's session and turn counter.
- /// Returns `true` if the channel had an active session.
+ /// Invalidate every retained session for a channel.
+ /// Returns `true` if the channel had any active session.
pub fn invalidate_channel(&mut self, channel_id: &Uuid) -> bool {
- self.turn_counts.remove(channel_id);
+ let before = self.sessions.len();
+ self.sessions.retain(|key, _| key.channel_id != *channel_id);
+ self.turn_counts
+ .retain(|key, _| key.channel_id != *channel_id);
+ self.session_lru
+ .retain(|key| self.sessions.contains_key(key));
self.core_sections.remove(channel_id);
self.canvas_sections.remove(channel_id);
- self.sessions.remove(channel_id).is_some()
+ self.sessions.len() != before
}
/// Invalidate all sessions and turn counters (e.g. after agent exit).
pub fn invalidate_all(&mut self) {
self.sessions.clear();
+ self.session_lru.clear();
self.turn_counts.clear();
self.heartbeat_session = None;
self.heartbeat_turn_count = 0;
@@ -133,10 +208,14 @@ impl SessionState {
self.canvas_sections.clear();
}
- #[cfg(test)]
- fn has_channel_state(&self, channel_id: &Uuid) -> bool {
- self.sessions.contains_key(channel_id)
- || self.turn_counts.contains_key(channel_id)
+ pub(crate) fn has_channel_state(&self, channel_id: &Uuid) -> bool {
+ self.sessions
+ .keys()
+ .any(|key| key.channel_id == *channel_id)
+ || self
+ .turn_counts
+ .keys()
+ .any(|key| key.channel_id == *channel_id)
|| self.core_sections.contains_key(channel_id)
|| self.canvas_sections.contains_key(channel_id)
}
@@ -211,6 +290,9 @@ pub struct AgentPool {
result_rx: mpsc::UnboundedReceiver,
pub join_set: JoinSet<()>,
task_map: HashMap,
+ /// Stable affinity for retained root sessions, including while the owning
+ /// agent slot is checked out on another channel.
+ session_owners: HashMap,
}
/// Result returned by a completed prompt task.
@@ -227,7 +309,7 @@ pub struct PromptResult {
/// Whether the prompt came from a channel event or a heartbeat.
#[derive(Debug)]
pub enum PromptSource {
- Channel(Uuid),
+ Channel(ConversationSessionKey),
Heartbeat,
}
@@ -235,19 +317,28 @@ pub enum PromptSource {
/// prompt completed naturally. The prompt result has already been consumed by
/// `select!`, so the harness must synthesize a successful result while still
/// honoring any load-bearing control signal semantics.
+fn invalidate_after_control_signal(
+ state: &mut SessionState,
+ source: &PromptSource,
+ control_signal: &ControlSignal,
+) {
+ if let (ControlSignal::SwitchModel(_), PromptSource::Channel(key)) = (control_signal, source) {
+ state.invalidate_channel(&key.channel_id);
+ } else {
+ state.invalidate(source);
+ }
+}
+
fn apply_completed_before_control_signal(
state: &mut SessionState,
source: &PromptSource,
control_signal: &ControlSignal,
) {
- // Rotate and SwitchModel both invalidate so the next turn creates a fresh
- // session. For SwitchModel the caller has already set `desired_model`, so
- // the fresh session applies the new model on its next creation.
if matches!(
control_signal,
ControlSignal::Rotate | ControlSignal::SwitchModel(_)
) {
- state.invalidate(source);
+ invalidate_after_control_signal(state, source, control_signal);
}
}
@@ -464,6 +555,8 @@ pub struct PromptContext {
/// `[Agent Memory β core]` section. On by default; disabled via
/// `--no-memory` / `BUZZ_ACP_NO_MEMORY`.
pub memory_enabled: bool,
+ /// Whether ACP sessions are isolated by human conversation root.
+ pub top_level_sessions: bool,
/// Harness identity string for NIP-AM `harness` field. Derived from the
/// configured `agent_command` at startup (e.g. `"goose"`, `"buzz-agent"`).
pub harness_name: String,
@@ -484,6 +577,7 @@ impl AgentPool {
result_rx,
join_set: JoinSet::new(),
task_map: HashMap::new(),
+ session_owners: HashMap::new(),
}
}
@@ -493,22 +587,48 @@ impl AgentPool {
/// Pass 2: any idle agent.
///
/// Returns `None` if all agents are checked out.
- pub fn try_claim(&mut self, channel_id: Option) -> Option {
- // Pass 1: prefer agent with existing session for this channel.
- if let Some(cid) = channel_id {
+ pub fn try_claim(
+ &mut self,
+ session_key: Option<&ConversationSessionKey>,
+ ) -> Option {
+ // Pass 1: retained roots have strict ownership. If their slot is busy,
+ // wait rather than creating a duplicate provider session in another slot.
+ if let Some(key) = session_key {
+ if key.root_event_id.is_some() {
+ if let Some(&owner) = self.session_owners.get(key) {
+ let owner_busy = self.task_map.values().any(|meta| meta.agent_index == owner);
+ let owner_retains_session = self
+ .agents
+ .get(owner)
+ .and_then(Option::as_ref)
+ .is_some_and(|agent| agent.state.contains_session(key));
+ if owner_busy || owner_retains_session {
+ return self.agents.get_mut(owner).and_then(Option::take);
+ }
+ // The slot died, was replaced, or evicted/invalidated this
+ // root while idle. Drop the stale reservation so a live
+ // slot can recreate the provider session.
+ self.session_owners.remove(key);
+ }
+ }
let idx = self.agents.iter().position(|slot| {
slot.as_ref()
- .map(|a| a.state.sessions.contains_key(&cid))
+ .map(|a| a.state.contains_session(key))
.unwrap_or(false)
});
if let Some(i) = idx {
+ self.session_owners.insert(key.clone(), i);
return self.agents[i].take();
}
}
- // Pass 2: first idle agent.
- let idx = self.agents.iter().position(|slot| slot.is_some());
- idx.map(|i| self.agents[i].take().unwrap())
+ // Pass 2: first idle agent. Reserve root affinity immediately so another
+ // turn for the same root cannot fall through while this slot is checked out.
+ let idx = self.agents.iter().position(|slot| slot.is_some())?;
+ if let Some(key) = session_key.filter(|key| key.root_event_id.is_some()) {
+ self.session_owners.insert(key.clone(), idx);
+ }
+ self.agents[idx].take()
}
/// Return an agent to its slot after a task completes.
@@ -524,6 +644,11 @@ impl AgentPool {
"BUG: return_agent called for slot {idx} which is already occupied β overwriting"
);
}
+ // Reconcile reservations whenever a slot returns. This clears roots
+ // evicted by the per-slot LRU, explicitly invalidated while checked
+ // out, or lost when a dead slot is replaced.
+ self.session_owners
+ .retain(|key, owner| *owner != idx || agent.state.contains_session(key));
self.agents[idx] = Some(agent);
}
@@ -534,10 +659,10 @@ impl AgentPool {
/// Whether any idle agent already has a session for `channel_id`.
/// Used to compute `affinity_hit` before calling `try_claim`.
- pub fn has_session_for(&self, channel_id: Uuid) -> bool {
+ pub fn has_session_for(&self, session_key: &ConversationSessionKey) -> bool {
self.agents.iter().any(|slot| {
slot.as_ref()
- .map(|a| a.state.sessions.contains_key(&channel_id))
+ .map(|a| a.state.contains_session(session_key))
.unwrap_or(false)
})
}
@@ -634,6 +759,14 @@ impl AgentPool {
&mut self.agents
}
+ /// Remove every root reservation owned by a slot that died or is about to
+ /// be replaced. Returns the number removed for diagnostics and tests.
+ pub fn clear_slot_session_owners(&mut self, index: usize) -> usize {
+ let before = self.session_owners.len();
+ self.session_owners.retain(|_, owner| *owner != index);
+ before - self.session_owners.len()
+ }
+
/// Remove the session for `channel_id` from all idle agents.
///
/// Called when the agent is removed from a channel β stale sessions
@@ -651,6 +784,8 @@ impl AgentPool {
}
}
}
+ self.session_owners
+ .retain(|key, _| key.channel_id != channel_id);
count
}
@@ -672,32 +807,68 @@ impl AgentPool {
channel_id: Uuid,
model_id: &str,
) -> IdleSwitchResult {
- let Some(agent) = self
+ let matching: Vec = self
.agents
- .iter_mut()
- .flatten()
- .find(|a| a.state.sessions.contains_key(&channel_id))
- else {
+ .iter()
+ .enumerate()
+ .filter_map(|(index, slot)| {
+ slot.as_ref()
+ .filter(|agent| agent.state.has_channel_state(&channel_id))
+ .map(|_| index)
+ })
+ .collect();
+ if matching.is_empty() {
return IdleSwitchResult::NoIdleAgent;
- };
+ }
- // Pre-cancel guard against the cached catalog. None = catalog not yet
- // populated (no session ever created); defer validation to apply time.
- if let Some(caps) = agent.model_capabilities.as_ref() {
- if !model_in_catalog(
- &caps.config_options_raw,
- caps.available_models_raw.as_ref(),
- model_id,
- ) {
- return IdleSwitchResult::UnsupportedModel;
+ // Validate every affected slot before mutating any of them.
+ for &index in &matching {
+ let agent = self.agents[index].as_ref().expect("matched idle slot");
+ if let Some(caps) = agent.model_capabilities.as_ref() {
+ if !model_in_catalog(
+ &caps.config_options_raw,
+ caps.available_models_raw.as_ref(),
+ model_id,
+ ) {
+ return IdleSwitchResult::UnsupportedModel;
+ }
}
}
- agent.desired_model = Some(model_id.to_string());
- agent.model_overridden = true;
- agent.state.invalidate_channel(&channel_id);
+ for index in matching {
+ let agent = self.agents[index].as_mut().expect("matched idle slot");
+ agent.desired_model = Some(model_id.to_string());
+ agent.model_overridden = true;
+ agent.state.invalidate_channel(&channel_id);
+ }
+ self.session_owners
+ .retain(|key, _| key.channel_id != channel_id);
IdleSwitchResult::Switched
}
+
+ /// Apply a busy channel model switch to every currently idle slot retaining
+ /// another root for that channel. The checked-out owner receives the
+ /// control signal separately and invalidates its own channel state.
+ pub fn switch_other_idle_channel_roots(&mut self, channel_id: Uuid, model_id: &str) {
+ let mut invalidated_slots = Vec::new();
+ for (index, agent) in self.agents.iter_mut().enumerate() {
+ let Some(agent) = agent.as_mut() else {
+ continue;
+ };
+ if agent.state.has_channel_state(&channel_id) {
+ agent.desired_model = Some(model_id.to_string());
+ agent.model_overridden = true;
+ agent.state.invalidate_channel(&channel_id);
+ invalidated_slots.push(index);
+ }
+ }
+ // Preserve the checked-out root owner's reservation until that task
+ // returns with its own channel state invalidated. Otherwise a reply
+ // arriving during cancellation could fall through to an idle slot.
+ self.session_owners.retain(|key, owner| {
+ key.channel_id != channel_id || !invalidated_slots.contains(owner)
+ });
+ }
}
/// Outcome of [`AgentPool::switch_idle_agent_model`].
@@ -1185,6 +1356,27 @@ fn send_prompt_result(
});
}
+pub(crate) fn conversation_session_key(
+ batch: &FlushBatch,
+ top_level_sessions: bool,
+) -> ConversationSessionKey {
+ let root_event_id = top_level_sessions
+ .then(|| {
+ batch.conversation_root.clone().or_else(|| {
+ batch.events.last().map(|event| {
+ crate::queue::parse_thread_tags(&event.event)
+ .root_event_id
+ .unwrap_or_else(|| event.event.id.to_hex())
+ })
+ })
+ })
+ .flatten();
+ ConversationSessionKey {
+ channel_id: batch.channel_id,
+ root_event_id,
+ }
+}
+
/// Core async function spawned for each prompt.
///
/// Lifecycle:
@@ -1208,11 +1400,11 @@ pub async fn run_prompt_task(
) {
// Is this a channel prompt or a heartbeat?
let source = match &batch {
- Some(b) => PromptSource::Channel(b.channel_id),
+ Some(b) => PromptSource::Channel(conversation_session_key(b, ctx.top_level_sessions)),
None => PromptSource::Heartbeat,
};
let observer_channel_id = match &source {
- PromptSource::Channel(channel_id) => Some(*channel_id),
+ PromptSource::Channel(key) => Some(key.channel_id),
PromptSource::Heartbeat => None,
};
let turn_started_at = chrono::Utc::now().to_rfc3339();
@@ -1311,10 +1503,11 @@ pub async fn run_prompt_task(
//
// Operator opt-out: `--no-memory` / `BUZZ_ACP_NO_MEMORY` skips the fetch.
if ctx.memory_enabled {
- if let (PromptSource::Channel(cid), Some(owner_pk)) =
+ if let (PromptSource::Channel(session_key), Some(owner_pk)) =
(&source, ctx.agent_owner_pubkey.as_ref())
{
- let is_new_channel_session = !agent.state.sessions.contains_key(cid);
+ let cid = &session_key.channel_id;
+ let is_new_channel_session = !agent.state.contains_session(session_key);
if is_new_channel_session && !agent.state.core_sections.contains_key(cid) {
// Bounded β we'd rather start the session with no core hint
// than block session creation on a stalled relay.
@@ -1362,8 +1555,9 @@ pub async fn run_prompt_task(
// prevents a stale revision A surviving a failed create and being re-used by
// the next attempt after the canvas was cleared.
let mut pending_canvas: Option<(Uuid, String)> = None;
- if let PromptSource::Channel(cid) = &source {
- let is_new_channel_session = !agent.state.sessions.contains_key(cid);
+ if let PromptSource::Channel(session_key) = &source {
+ let cid = &session_key.channel_id;
+ let is_new_channel_session = !agent.state.contains_session(session_key);
if is_new_channel_session && !agent.state.canvas_sections.contains_key(cid) {
// Resolve DM status: prefer the startup cache, lazy-fetch as fallback.
// Unknown β treat as DM (fail-closed).
@@ -1385,26 +1579,31 @@ pub async fn run_prompt_task(
// The core section to fold into the system prompt for this turn's session.
// Channel-scoped; heartbeats carry no owner core.
let agent_core: Option = match &source {
- PromptSource::Channel(cid) => agent.state.core_sections.get(cid).cloned(),
+ PromptSource::Channel(session_key) => agent
+ .state
+ .core_sections
+ .get(&session_key.channel_id)
+ .cloned(),
PromptSource::Heartbeat => None,
};
// The canvas metadata section β channel-scoped, absent for heartbeats/DMs.
// Prefer the committed cache; fall back to pending (for new sessions being created now).
let agent_canvas: Option = match &source {
- PromptSource::Channel(cid) => agent
+ PromptSource::Channel(session_key) => agent
.state
.canvas_sections
- .get(cid)
+ .get(&session_key.channel_id)
.cloned()
.or_else(|| pending_canvas.as_ref().map(|(_, s)| s.clone())),
PromptSource::Heartbeat => None,
};
let (session_id, is_new_session) = match &source {
- PromptSource::Channel(cid) => {
- if let Some(sid) = agent.state.sessions.get(cid) {
- (sid.clone(), false)
+ PromptSource::Channel(session_key) => {
+ let cid = &session_key.channel_id;
+ if let Some(sid) = agent.state.get_session(session_key) {
+ (sid, false)
} else {
// Create new session with model application.
match create_session_and_apply_model(
@@ -1420,7 +1619,7 @@ pub async fn run_prompt_task(
target: "pool::session",
"created session {sid} for channel {cid}"
);
- agent.state.sessions.insert(*cid, sid.clone());
+ agent.state.insert_session(session_key.clone(), sid.clone());
// Commit canvas only after session creation succeeds (I3).
if let Some((pending_cid, section)) = pending_canvas.take() {
agent.state.canvas_sections.insert(pending_cid, section);
@@ -1514,8 +1713,10 @@ pub async fn run_prompt_task(
);
if is_new_session {
- if let (PromptSource::Channel(cid), Some(ref initial_msg)) = (&source, &ctx.initial_message)
+ if let (PromptSource::Channel(session_key), Some(ref initial_msg)) =
+ (&source, &ctx.initial_message)
{
+ let cid = session_key.channel_id;
tracing::info!(
target: "pool::session",
"sending initial_message to session {session_id} for channel {cid}"
@@ -1685,7 +1886,7 @@ pub async fn run_prompt_task(
};
let conversation_context = if ctx.context_message_limit > 0 {
- fetch_conversation_context(b, &channel_info, &ctx).await
+ fetch_conversation_context(b, &channel_info, &ctx, is_new_session).await
} else {
None
};
@@ -1808,7 +2009,11 @@ pub async fn run_prompt_task(
{
Ok(stop_reason) => {
log_stop_reason(&source, &stop_reason);
- agent.state.invalidate(&source);
+ invalidate_after_control_signal(
+ &mut agent.state,
+ &source,
+ &control_signal,
+ );
let retry_batch =
requeue_cancelled_batch(&ctx, control_signal, batch);
@@ -1941,8 +2146,12 @@ pub async fn run_prompt_task(
let limit = ctx.max_turns_per_session;
if limit > 0 {
match &source {
- PromptSource::Channel(cid) => {
- let count = agent.state.turn_counts.entry(*cid).or_insert(0);
+ PromptSource::Channel(session_key) => {
+ let count = agent
+ .state
+ .turn_counts
+ .entry(session_key.clone())
+ .or_insert(0);
*count += 1;
*count >= limit
}
@@ -2446,6 +2655,7 @@ async fn fetch_conversation_context(
batch: &FlushBatch,
channel_info: &Option,
ctx: &PromptContext,
+ is_new_session: bool,
) -> Option {
let limit = ctx.context_message_limit;
let is_dm = channel_info
@@ -2467,6 +2677,18 @@ async fn fetch_conversation_context(
return fetch_dm_context(batch.channel_id, limit, &ctx.rest_client).await;
}
+ // Experiment: seed only a fresh top-level conversation. Replies use their
+ // thread context, and existing sessions already carry their own history.
+ if ctx.top_level_sessions && is_new_session {
+ let triggering_ids: HashSet = batch
+ .events
+ .iter()
+ .map(|event| event.event.id.to_hex())
+ .collect();
+ return fetch_channel_context(batch.channel_id, limit, &triggering_ids, &ctx.rest_client)
+ .await;
+ }
+
None
}
@@ -2500,7 +2722,8 @@ fn collect_prompt_pubkeys(
let context_messages = match conversation_context {
Some(ConversationContext::Thread { messages, .. })
- | Some(ConversationContext::Dm { messages, .. }) => Some(messages),
+ | Some(ConversationContext::Dm { messages, .. })
+ | Some(ConversationContext::Channel { messages, .. }) => Some(messages),
None => None,
};
@@ -2685,6 +2908,122 @@ async fn fetch_thread_context(
.await
}
+async fn fetch_channel_context(
+ channel_id: Uuid,
+ limit: u32,
+ excluded_event_ids: &HashSet,
+ rest: &RestClient,
+) -> Option {
+ let filters = serde_json::json!([{
+ "kinds": [buzz_core::kind::KIND_STREAM_MESSAGE, buzz_core::kind::KIND_STREAM_MESSAGE_V2],
+ "#h": [channel_id.to_string()],
+ "limit": limit,
+ "top_level": true,
+ "include_summaries": true
+ }]);
+ match timeout(CONTEXT_FETCH_TIMEOUT, rest.query_value(&filters)).await {
+ Ok(Ok(json)) => {
+ if !channel_window_response_is_complete(&json) {
+ tracing::warn!(
+ channel_id = %channel_id,
+ "channel context window response has invalid bounds overlay cardinality"
+ );
+ None
+ } else {
+ parse_nostr_channel_response(json, limit, excluded_event_ids)
+ }
+ }
+ Ok(Err(error)) => {
+ tracing::warn!(channel_id = %channel_id, "channel context fetch failed: {error}");
+ None
+ }
+ Err(_) => {
+ tracing::warn!(channel_id = %channel_id, "channel context fetch timed out");
+ None
+ }
+ }
+}
+
+fn channel_window_response_is_complete(json: &serde_json::Value) -> bool {
+ json.as_array().is_some_and(|events| {
+ events
+ .iter()
+ .filter(|event| {
+ event.get("kind").and_then(|kind| kind.as_u64())
+ == Some(buzz_core::kind::KIND_WINDOW_BOUNDS as u64)
+ })
+ .count()
+ == 1
+ })
+}
+
+fn parse_nostr_channel_response(
+ json: serde_json::Value,
+ limit: u32,
+ excluded_event_ids: &HashSet,
+) -> Option {
+ let events = json.as_array()?;
+ let reply_roots: HashSet = events
+ .iter()
+ .filter(|event| {
+ event.get("kind").and_then(|kind| kind.as_u64())
+ == Some(buzz_core::kind::KIND_THREAD_SUMMARY as u64)
+ })
+ .filter_map(|event| {
+ event.get("tags")?.as_array()?.iter().find_map(|tag| {
+ let parts = tag.as_array()?;
+ (parts.first()?.as_str()? == "e")
+ .then(|| parts.get(1)?.as_str().map(str::to_string))
+ .flatten()
+ })
+ })
+ .collect();
+ let mut messages: Vec<(u64, ContextMessage)> = events
+ .iter()
+ .filter(|event| {
+ matches!(
+ event.get("kind").and_then(|kind| kind.as_u64()),
+ Some(9 | 9092)
+ ) && !event
+ .get("tags")
+ .and_then(|tags| tags.as_array())
+ .is_some_and(|tags| {
+ tags.iter().any(|tag| {
+ tag.as_array().is_some_and(|parts| {
+ parts.first().and_then(|part| part.as_str()) == Some("e")
+ })
+ })
+ })
+ })
+ .filter_map(|event| {
+ let timestamp = event
+ .get("created_at")
+ .and_then(|value| value.as_u64())
+ .unwrap_or(0);
+ let id = event
+ .get("id")
+ .and_then(|value| value.as_str())
+ .unwrap_or("");
+ if excluded_event_ids.contains(id) {
+ return None;
+ }
+ let mut message = json_to_context_message(event)?;
+ message.has_replies = reply_roots.contains(id);
+ Some((timestamp, message))
+ })
+ .collect();
+ messages.sort_by_key(|(timestamp, _)| *timestamp);
+ let messages: Vec<_> = messages.into_iter().map(|(_, message)| message).collect();
+ if messages.is_empty() {
+ return None;
+ }
+ Some(ConversationContext::Channel {
+ total: messages.len(),
+ truncated: messages.len() >= limit as usize,
+ messages,
+ })
+}
+
/// Fetch DM context via Nostr query: recent messages in channel by `#h` tag.
async fn fetch_dm_context(
channel_id: Uuid,
@@ -2830,6 +3169,7 @@ fn json_to_context_message(obj: &serde_json::Value) -> Option {
pubkey: pubkey.to_string(),
timestamp,
content: content.to_string(),
+ has_replies: false,
})
}
@@ -3005,7 +3345,7 @@ fn classify_control_cancel_failure(
/// Log a stop reason at the appropriate tracing level.
fn log_stop_reason(source: &PromptSource, stop_reason: &StopReason) {
let label = match source {
- PromptSource::Channel(cid) => format!("channel {cid}"),
+ PromptSource::Channel(session_key) => format!("channel {}", session_key.channel_id),
PromptSource::Heartbeat => "heartbeat".to_string(),
};
match stop_reason {
@@ -4046,6 +4386,33 @@ mod tests {
assert_eq!(msg.timestamp, "2026-03-15T16:30:00+00:00");
}
+ #[test]
+ fn test_channel_context_marks_summaries_and_excludes_triggering_root() {
+ let trigger = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+ let prior = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+ let json = json!([
+ {"id": trigger, "kind": 9, "pubkey": "human", "content": "current", "created_at": 20},
+ {"id": prior, "kind": 9, "pubkey": "human", "content": "prior", "created_at": 10},
+ {"id": "summary", "kind": buzz_core::kind::KIND_THREAD_SUMMARY, "tags": [["e", prior]]},
+ {"id": "bounds", "kind": buzz_core::kind::KIND_WINDOW_BOUNDS},
+ {"id": "reply", "kind": 9, "pubkey": "other", "content": "reply body", "created_at": 15, "tags": [["e", prior, "", "root"]]}
+ ]);
+ assert!(channel_window_response_is_complete(&json));
+ assert!(!channel_window_response_is_complete(&json!([])));
+ assert!(!channel_window_response_is_complete(&json!([
+ {"kind": buzz_core::kind::KIND_WINDOW_BOUNDS},
+ {"kind": buzz_core::kind::KIND_WINDOW_BOUNDS}
+ ])));
+ let excluded = HashSet::from([trigger.to_string()]);
+ let context = parse_nostr_channel_response(json, 12, &excluded).expect("channel context");
+ let ConversationContext::Channel { messages, .. } = context else {
+ panic!("channel context")
+ };
+ assert_eq!(messages.len(), 1);
+ assert_eq!(messages[0].content, "prior");
+ assert!(messages[0].has_replies);
+ }
+
#[test]
fn test_json_to_context_message_missing_content() {
let obj = json!({ "pubkey": "abc" });
@@ -4066,6 +4433,7 @@ mod tests {
.unwrap();
let author_hex = event.pubkey.to_hex();
let batch = FlushBatch {
+ conversation_root: None,
channel_id: Uuid::new_v4(),
events: vec![crate::queue::BatchEvent {
event,
@@ -4077,6 +4445,7 @@ mod tests {
};
let context = ConversationContext::Thread {
messages: vec![ContextMessage {
+ has_replies: false,
pubkey: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(),
timestamp: "2026-03-25T05:51:25Z".into(),
content: "follow up".into(),
@@ -4196,10 +4565,14 @@ mod tests {
let ch_a = Uuid::new_v4();
let ch_b = Uuid::new_v4();
let mut s = SessionState::default();
- s.sessions.insert(ch_a, "sess-a".into());
- s.sessions.insert(ch_b, "sess-b".into());
- s.turn_counts.insert(ch_a, 5);
- s.turn_counts.insert(ch_b, 3);
+ s.sessions
+ .insert(ConversationSessionKey::channel(ch_a), "sess-a".into());
+ s.sessions
+ .insert(ConversationSessionKey::channel(ch_b), "sess-b".into());
+ s.turn_counts
+ .insert(ConversationSessionKey::channel(ch_a), 5);
+ s.turn_counts
+ .insert(ConversationSessionKey::channel(ch_b), 3);
s.core_sections.insert(ch_a, "core-a".into());
s.core_sections.insert(ch_b, "core-b".into());
s.heartbeat_session = Some("sess-hb".into());
@@ -4207,22 +4580,116 @@ mod tests {
(s, ch_a, ch_b)
}
+ #[test]
+ fn test_conversation_session_key_is_channel_scoped_when_disabled() {
+ let batch = one_event_batch(Uuid::new_v4());
+ let key = conversation_session_key(&batch, false);
+ assert_eq!(key.channel_id, batch.channel_id);
+ assert_eq!(key.root_event_id, None);
+ }
+
+ #[test]
+ fn test_conversation_session_key_uses_explicit_root_when_enabled() {
+ let mut batch = one_event_batch(Uuid::new_v4());
+ batch.conversation_root = Some("root-a".into());
+ let key = conversation_session_key(&batch, true);
+ assert_eq!(key.channel_id, batch.channel_id);
+ assert_eq!(key.root_event_id.as_deref(), Some("root-a"));
+ }
+
+ #[test]
+ fn test_session_state_lru_evicts_oldest_root_and_turn_counter() {
+ let channel_id = Uuid::new_v4();
+ let mut state = SessionState::default();
+ let oldest = ConversationSessionKey {
+ channel_id,
+ root_event_id: Some("root-0".into()),
+ };
+ for index in 0..=SessionState::MAX_CHANNEL_SESSIONS {
+ let key = ConversationSessionKey {
+ channel_id,
+ root_event_id: Some(format!("root-{index}")),
+ };
+ state.turn_counts.insert(key.clone(), index as u32);
+ state.insert_session(key, format!("session-{index}"));
+ }
+ assert_eq!(state.sessions.len(), SessionState::MAX_CHANNEL_SESSIONS);
+ assert!(!state.sessions.contains_key(&oldest));
+ assert!(!state.turn_counts.contains_key(&oldest));
+ }
+
+ #[test]
+ fn test_legacy_channel_sessions_are_not_subject_to_root_lru_cap() {
+ let mut state = SessionState::default();
+ for _ in 0..=SessionState::MAX_CHANNEL_SESSIONS {
+ let channel_id = Uuid::new_v4();
+ state.insert_session(
+ ConversationSessionKey::channel(channel_id),
+ channel_id.to_string(),
+ );
+ }
+ assert_eq!(state.sessions.len(), SessionState::MAX_CHANNEL_SESSIONS + 1);
+ }
+
+ #[test]
+ fn test_session_state_lookup_refreshes_lru() {
+ let channel_id = Uuid::new_v4();
+ let mut state = SessionState::default();
+ let first = ConversationSessionKey {
+ channel_id,
+ root_event_id: Some("first".into()),
+ };
+ state.insert_session(first.clone(), "first-session".into());
+ for index in 1..SessionState::MAX_CHANNEL_SESSIONS {
+ state.insert_session(
+ ConversationSessionKey {
+ channel_id,
+ root_event_id: Some(format!("root-{index}")),
+ },
+ format!("session-{index}"),
+ );
+ }
+ assert_eq!(state.get_session(&first).as_deref(), Some("first-session"));
+ state.insert_session(
+ ConversationSessionKey {
+ channel_id,
+ root_event_id: Some("new-root".into()),
+ },
+ "new-session".into(),
+ );
+ assert!(state.sessions.contains_key(&first));
+ }
+
#[test]
fn test_rotate_after_natural_completion_invalidates_channel_state() {
let (mut s, ch_a, ch_b) = make_state();
apply_completed_before_control_signal(
&mut s,
- &PromptSource::Channel(ch_a),
+ &PromptSource::Channel(ConversationSessionKey::channel(ch_a)),
&ControlSignal::Rotate,
);
- assert!(!s.sessions.contains_key(&ch_a));
- assert!(!s.turn_counts.contains_key(&ch_a));
+ assert!(!s
+ .sessions
+ .contains_key(&ConversationSessionKey::channel(ch_a)));
+ assert!(!s
+ .turn_counts
+ .contains_key(&ConversationSessionKey::channel(ch_a)));
assert!(!s.core_sections.contains_key(&ch_a));
assert!(!s.has_channel_state(&ch_a));
- assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b");
- assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3);
+ assert_eq!(
+ s.sessions
+ .get(&ConversationSessionKey::channel(ch_b))
+ .unwrap(),
+ "sess-b"
+ );
+ assert_eq!(
+ *s.turn_counts
+ .get(&ConversationSessionKey::channel(ch_b))
+ .unwrap(),
+ 3
+ );
assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b");
assert_eq!(s.heartbeat_session.as_deref(), Some("sess-hb"));
assert_eq!(s.heartbeat_turn_count, 7);
@@ -4234,28 +4701,59 @@ mod tests {
apply_completed_before_control_signal(
&mut s,
- &PromptSource::Channel(ch_a),
+ &PromptSource::Channel(ConversationSessionKey::channel(ch_a)),
&ControlSignal::Cancel,
);
- assert_eq!(s.sessions.get(&ch_a).unwrap(), "sess-a");
- assert_eq!(*s.turn_counts.get(&ch_a).unwrap(), 5);
+ assert_eq!(
+ s.sessions
+ .get(&ConversationSessionKey::channel(ch_a))
+ .unwrap(),
+ "sess-a"
+ );
+ assert_eq!(
+ *s.turn_counts
+ .get(&ConversationSessionKey::channel(ch_a))
+ .unwrap(),
+ 5
+ );
assert_eq!(s.core_sections.get(&ch_a).unwrap(), "core-a");
- assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b");
+ assert_eq!(
+ s.sessions
+ .get(&ConversationSessionKey::channel(ch_b))
+ .unwrap(),
+ "sess-b"
+ );
}
#[test]
fn test_invalidate_channel_clears_session_and_turn_count() {
let (mut s, ch_a, ch_b) = make_state();
- s.invalidate(&PromptSource::Channel(ch_a));
-
- assert!(!s.sessions.contains_key(&ch_a));
- assert!(!s.turn_counts.contains_key(&ch_a));
+ s.invalidate(&PromptSource::Channel(ConversationSessionKey::channel(
+ ch_a,
+ )));
+
+ assert!(!s
+ .sessions
+ .contains_key(&ConversationSessionKey::channel(ch_a)));
+ assert!(!s
+ .turn_counts
+ .contains_key(&ConversationSessionKey::channel(ch_a)));
assert!(!s.core_sections.contains_key(&ch_a));
assert!(!s.has_channel_state(&ch_a));
// ch_b untouched
- assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b");
- assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3);
+ assert_eq!(
+ s.sessions
+ .get(&ConversationSessionKey::channel(ch_b))
+ .unwrap(),
+ "sess-b"
+ );
+ assert_eq!(
+ *s.turn_counts
+ .get(&ConversationSessionKey::channel(ch_b))
+ .unwrap(),
+ 3
+ );
assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b");
// heartbeat untouched
assert_eq!(s.heartbeat_session.as_deref(), Some("sess-hb"));
@@ -4271,8 +4769,18 @@ mod tests {
assert_eq!(s.heartbeat_turn_count, 0);
// channels untouched
assert_eq!(s.sessions.len(), 2);
- assert_eq!(*s.turn_counts.get(&ch_a).unwrap(), 5);
- assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3);
+ assert_eq!(
+ *s.turn_counts
+ .get(&ConversationSessionKey::channel(ch_a))
+ .unwrap(),
+ 5
+ );
+ assert_eq!(
+ *s.turn_counts
+ .get(&ConversationSessionKey::channel(ch_b))
+ .unwrap(),
+ 3
+ );
assert_eq!(s.core_sections.get(&ch_a).unwrap(), "core-a");
assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b");
}
@@ -4293,13 +4801,25 @@ mod tests {
fn test_invalidate_nonexistent_channel_is_noop() {
let (mut s, ch_a, ch_b) = make_state();
let ghost = Uuid::new_v4();
- s.invalidate(&PromptSource::Channel(ghost));
+ s.invalidate(&PromptSource::Channel(ConversationSessionKey::channel(
+ ghost,
+ )));
// Everything still intact.
assert_eq!(s.sessions.len(), 2);
assert_eq!(s.turn_counts.len(), 2);
- assert_eq!(*s.turn_counts.get(&ch_a).unwrap(), 5);
- assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3);
+ assert_eq!(
+ *s.turn_counts
+ .get(&ConversationSessionKey::channel(ch_a))
+ .unwrap(),
+ 5
+ );
+ assert_eq!(
+ *s.turn_counts
+ .get(&ConversationSessionKey::channel(ch_b))
+ .unwrap(),
+ 3
+ );
assert_eq!(s.core_sections.get(&ch_a).unwrap(), "core-a");
assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b");
}
@@ -4317,13 +4837,27 @@ mod tests {
fn test_invalidate_channel_returns_true_when_session_existed() {
let (mut s, ch_a, ch_b) = make_state();
assert!(s.invalidate_channel(&ch_a));
- assert!(!s.sessions.contains_key(&ch_a));
- assert!(!s.turn_counts.contains_key(&ch_a));
+ assert!(!s
+ .sessions
+ .contains_key(&ConversationSessionKey::channel(ch_a)));
+ assert!(!s
+ .turn_counts
+ .contains_key(&ConversationSessionKey::channel(ch_a)));
assert!(!s.core_sections.contains_key(&ch_a));
assert!(!s.has_channel_state(&ch_a));
// ch_b untouched
- assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b");
- assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3);
+ assert_eq!(
+ s.sessions
+ .get(&ConversationSessionKey::channel(ch_b))
+ .unwrap(),
+ "sess-b"
+ );
+ assert_eq!(
+ *s.turn_counts
+ .get(&ConversationSessionKey::channel(ch_b))
+ .unwrap(),
+ 3
+ );
assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b");
// heartbeat untouched
assert_eq!(s.heartbeat_session.as_deref(), Some("sess-hb"));
@@ -4349,12 +4883,26 @@ mod tests {
for ch in &removed {
s.invalidate_channel(ch);
}
- assert!(!s.sessions.contains_key(&ch_a));
- assert!(!s.turn_counts.contains_key(&ch_a));
+ assert!(!s
+ .sessions
+ .contains_key(&ConversationSessionKey::channel(ch_a)));
+ assert!(!s
+ .turn_counts
+ .contains_key(&ConversationSessionKey::channel(ch_a)));
assert!(!s.core_sections.contains_key(&ch_a));
assert!(!s.has_channel_state(&ch_a));
- assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b");
- assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3);
+ assert_eq!(
+ s.sessions
+ .get(&ConversationSessionKey::channel(ch_b))
+ .unwrap(),
+ "sess-b"
+ );
+ assert_eq!(
+ *s.turn_counts
+ .get(&ConversationSessionKey::channel(ch_b))
+ .unwrap(),
+ 3
+ );
assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b");
}
@@ -4368,14 +4916,48 @@ mod tests {
// re-creates a fresh session that re-applies the new desired_model.
apply_completed_before_control_signal(
&mut s,
- &PromptSource::Channel(ch_a),
+ &PromptSource::Channel(ConversationSessionKey::channel(ch_a)),
&ControlSignal::SwitchModel("gpt-5".into()),
);
assert!(!s.has_channel_state(&ch_a));
// ch_b untouched β the switch is channel-scoped.
- assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b");
- assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3);
+ assert_eq!(
+ s.sessions
+ .get(&ConversationSessionKey::channel(ch_b))
+ .unwrap(),
+ "sess-b"
+ );
+ assert_eq!(
+ *s.turn_counts
+ .get(&ConversationSessionKey::channel(ch_b))
+ .unwrap(),
+ 3
+ );
+ }
+
+ #[test]
+ fn test_busy_switch_model_invalidation_clears_all_roots_for_channel() {
+ let mut s = SessionState::default();
+ let channel_id = Uuid::new_v4();
+ let root_a = ConversationSessionKey {
+ channel_id,
+ root_event_id: Some("root-a".into()),
+ };
+ let root_b = ConversationSessionKey {
+ channel_id,
+ root_event_id: Some("root-b".into()),
+ };
+ s.insert_session(root_a.clone(), "session-a".into());
+ s.insert_session(root_b, "session-b".into());
+
+ invalidate_after_control_signal(
+ &mut s,
+ &PromptSource::Channel(root_a),
+ &ControlSignal::SwitchModel("gpt-5".into()),
+ );
+
+ assert!(!s.has_channel_state(&channel_id));
}
// ββ requeue_cancelled_batch ββββββββββββββββββββββββββββββββββββββββββββ
@@ -4393,6 +4975,7 @@ mod tests {
.unwrap();
FlushBatch {
channel_id,
+ conversation_root: None,
events: vec![crate::queue::BatchEvent {
event,
prompt_tag: "test".into(),
@@ -5244,6 +5827,7 @@ mod tests {
agent_owner_pubkey: owner_pubkey,
memory_enabled: false,
harness_name: "goose".to_string(),
+ top_level_sessions: false,
}
}
@@ -5296,14 +5880,17 @@ mod tests {
fn test_invalidate_channel_clears_canvas_section() {
let ch = Uuid::new_v4();
let mut s = SessionState::default();
- s.sessions.insert(ch, "sess".into());
+ s.sessions
+ .insert(ConversationSessionKey::channel(ch), "sess".into());
s.canvas_sections
.insert(ch, "[Channel Canvas]\nrev abc".into());
s.invalidate_channel(&ch);
assert!(!s.canvas_sections.contains_key(&ch));
- assert!(!s.sessions.contains_key(&ch));
+ assert!(!s
+ .sessions
+ .contains_key(&ConversationSessionKey::channel(ch)));
}
#[test]
@@ -5313,7 +5900,8 @@ mod tests {
let mut s = SessionState::default();
s.canvas_sections.insert(ch_a, "canvas-a".into());
s.canvas_sections.insert(ch_b, "canvas-b".into());
- s.sessions.insert(ch_a, "sess-a".into());
+ s.sessions
+ .insert(ConversationSessionKey::channel(ch_a), "sess-a".into());
s.invalidate_all();
@@ -5326,8 +5914,10 @@ mod tests {
let ch_a = Uuid::new_v4();
let ch_b = Uuid::new_v4();
let mut s = SessionState::default();
- s.sessions.insert(ch_a, "sess-a".into());
- s.sessions.insert(ch_b, "sess-b".into());
+ s.sessions
+ .insert(ConversationSessionKey::channel(ch_a), "sess-a".into());
+ s.sessions
+ .insert(ConversationSessionKey::channel(ch_b), "sess-b".into());
s.canvas_sections.insert(ch_a, "canvas-a".into());
s.canvas_sections.insert(ch_b, "canvas-b".into());
diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs
index dc4e5bffb0..8e71b29522 100644
--- a/crates/buzz-acp/src/queue.rs
+++ b/crates/buzz-acp/src/queue.rs
@@ -49,6 +49,8 @@ pub struct QueuedEvent {
pub received_at: Instant,
/// Tag identifying which rule (or mode) matched this event.
pub prompt_tag: String,
+ /// Conversation root used only by the top-level-session experiment.
+ pub conversation_root: Option,
}
/// A single event inside a [`FlushBatch`].
@@ -77,6 +79,7 @@ pub enum CancelReason {
pub struct FlushBatch {
pub channel_id: Uuid,
pub events: Vec,
+ pub conversation_root: Option,
/// Events from a cancelled batch that triggered this re-prompt.
/// Empty for normal (non-cancel) batches. When non-empty, `format_prompt()`
/// produces a merged prompt with annotated sections, framed per
@@ -89,6 +92,13 @@ pub struct FlushBatch {
pub cancel_reason: Option,
}
+#[derive(Debug, Clone)]
+struct CancelledBatch {
+ conversation_root: Option,
+ events: Vec,
+ reason: CancelReason,
+}
+
/// Per-channel event queue with per-channel in-flight enforcement.
///
/// # State Machine
@@ -145,14 +155,9 @@ pub struct EventQueue {
/// Per-channel retry attempt counter for exponential backoff / dead-lettering.
retry_counts: HashMap,
dedup_mode: DedupMode,
- /// Events from cancelled batches, keyed by channel. Merged into the next
- /// `FlushBatch` for that channel as `cancelled_events` so `format_prompt()`
- /// can produce annotated "[Previous request β interrupted]" sections.
- cancelled_batches: HashMap>,
- /// Why each channel's cancelled batch was cancelled (steer vs interrupt).
- /// Set by `requeue_as_cancelled`, consumed by `flush_next` to set
- /// `FlushBatch::cancel_reason`. Keyed by channel, cleared on flush.
- cancel_reasons: HashMap,
+ /// Cancelled batches awaiting redispatch, preserving conversation identity.
+ /// Multiple roots may wait behind the same channel, but are never merged.
+ cancelled_batches: HashMap>,
/// Events withheld from `queues` while a goose-native steer is in flight
/// for that event. Invisible to `flush_next` / `has_flushable_work` /
/// `drain` (the events have been moved out of `queues`), so the queue's
@@ -186,7 +191,6 @@ impl EventQueue {
retry_counts: HashMap::new(),
dedup_mode,
cancelled_batches: HashMap::new(),
- cancel_reasons: HashMap::new(),
withheld_native_steer: HashMap::new(),
in_flight_deadline: Duration::from_secs(DEFAULT_IN_FLIGHT_DEADLINE_SECS),
}
@@ -291,19 +295,31 @@ impl EventQueue {
.copied();
match cancelled_id {
Some(id) => {
- // Move cancelled events into the regular events slot.
- // No new events to merge β re-dispatch the original batch.
- let cancelled = self.cancelled_batches.remove(&id).unwrap_or_default();
- let cancel_reason = self.cancel_reasons.remove(&id);
+ // No new events to merge β re-dispatch the oldest cancelled
+ // batch unchanged under its original conversation root.
+ let cancelled = self
+ .cancelled_batches
+ .get_mut(&id)
+ .and_then(VecDeque::pop_front)
+ .expect("cancelled channel must have a batch");
+ if self
+ .cancelled_batches
+ .get(&id)
+ .is_some_and(VecDeque::is_empty)
+ {
+ self.cancelled_batches.remove(&id);
+ }
self.in_flight_channels.insert(id);
self.in_flight_deadlines
.insert(id, now + self.in_flight_deadline);
- self.in_flight_batch_sizes.insert(id, cancelled.len());
+ self.in_flight_batch_sizes
+ .insert(id, cancelled.events.len());
return Some(FlushBatch {
channel_id: id,
- events: cancelled,
+ conversation_root: cancelled.conversation_root,
+ events: cancelled.events,
cancelled_events: vec![],
- cancel_reason,
+ cancel_reason: Some(cancelled.reason),
});
}
None => return None,
@@ -311,9 +327,55 @@ impl EventQueue {
}
};
+ let queued_root = self
+ .queues
+ .get(&channel_id)
+ .and_then(|queue| queue.front())
+ .and_then(|event| event.conversation_root.clone());
+ let cancelled_root = self
+ .cancelled_batches
+ .get(&channel_id)
+ .and_then(|batches| batches.front())
+ .and_then(|batch| batch.conversation_root.clone());
+ if self.cancelled_batches.contains_key(&channel_id) && cancelled_root != queued_root {
+ let cancelled = self
+ .cancelled_batches
+ .get_mut(&channel_id)
+ .and_then(VecDeque::pop_front)
+ .expect("cancelled channel must have a batch");
+ if self
+ .cancelled_batches
+ .get(&channel_id)
+ .is_some_and(VecDeque::is_empty)
+ {
+ self.cancelled_batches.remove(&channel_id);
+ }
+ self.in_flight_channels.insert(channel_id);
+ self.in_flight_deadlines
+ .insert(channel_id, now + self.in_flight_deadline);
+ self.in_flight_batch_sizes
+ .insert(channel_id, cancelled.events.len());
+ return Some(FlushBatch {
+ channel_id,
+ conversation_root: cancelled.conversation_root,
+ events: cancelled.events,
+ cancelled_events: vec![],
+ cancel_reason: Some(cancelled.reason),
+ });
+ }
+
// Drain up to MAX_BATCH_EVENTS; leave any remainder in the queue.
let queue = self.queues.entry(channel_id).or_default();
- let drain_count = MAX_BATCH_EVENTS.min(queue.len());
+ // Never merge independent experiment roots into one ACP turn. Drain only
+ // the contiguous head conversation while retaining channel serialization.
+ let head_root = queue
+ .front()
+ .and_then(|event| event.conversation_root.clone());
+ let same_root_count = queue
+ .iter()
+ .take_while(|event| event.conversation_root == head_root)
+ .count();
+ let drain_count = MAX_BATCH_EVENTS.min(same_root_count);
let mut events: Vec = queue
.drain(..drain_count)
.map(|qe| BatchEvent {
@@ -338,20 +400,26 @@ impl EventQueue {
.insert(channel_id, now + self.in_flight_deadline);
self.in_flight_batch_sizes.insert(channel_id, events.len());
- // Merge any cancelled events stored by requeue_as_cancelled().
- let cancelled_events = self
+ // Merge only a cancelled batch for the same conversation root.
+ let cancelled = self
.cancelled_batches
- .remove(&channel_id)
- .unwrap_or_default();
- let cancel_reason = if cancelled_events.is_empty() {
- self.cancel_reasons.remove(&channel_id);
- None
- } else {
- self.cancel_reasons.remove(&channel_id)
- };
+ .get_mut(&channel_id)
+ .and_then(VecDeque::pop_front);
+ if self
+ .cancelled_batches
+ .get(&channel_id)
+ .is_some_and(VecDeque::is_empty)
+ {
+ self.cancelled_batches.remove(&channel_id);
+ }
+ let (cancelled_events, cancel_reason) = cancelled.map_or_else(
+ || (vec![], None),
+ |batch| (batch.events, Some(batch.reason)),
+ );
Some(FlushBatch {
channel_id,
+ conversation_root: head_root,
events,
cancelled_events,
cancel_reason,
@@ -407,6 +475,7 @@ impl EventQueue {
/// `mark_complete` separately.
pub fn requeue(&mut self, batch: FlushBatch) -> Option {
let channel_id = batch.channel_id;
+ let conversation_root = batch.conversation_root.clone();
let attempt = {
let count = self.retry_counts.entry(channel_id).or_insert(0);
*count += 1;
@@ -459,6 +528,7 @@ impl EventQueue {
event: be.event,
prompt_tag: be.prompt_tag,
received_at: be.received_at, // preserve original timestamp (#46)
+ conversation_root: conversation_root.clone(),
});
}
// Enforce per-channel cap: trim oldest (back) events if requeue pushed
@@ -486,6 +556,7 @@ impl EventQueue {
/// caller must call `mark_complete` separately.
pub fn requeue_preserve_timestamps(&mut self, batch: FlushBatch) {
let channel_id = batch.channel_id;
+ let conversation_root = batch.conversation_root.clone();
let queue = self.queues.entry(channel_id).or_default();
// Push to front in reverse order so original order is preserved.
for be in batch.events.into_iter().rev() {
@@ -494,6 +565,7 @@ impl EventQueue {
event: be.event,
prompt_tag: be.prompt_tag,
received_at: be.received_at,
+ conversation_root: conversation_root.clone(),
});
}
// Enforce per-channel cap: trim newest (back) events if over limit.
@@ -519,11 +591,26 @@ impl EventQueue {
/// the generic queue β they are stored separately and merged by
/// `flush_next()`. No retry throttle, no backoff.
pub fn requeue_as_cancelled(&mut self, batch: FlushBatch, reason: CancelReason) {
- let entry = self.cancelled_batches.entry(batch.channel_id).or_default();
- // Preserve any already-cancelled events from a prior cancel (double-cancel).
- entry.extend(batch.cancelled_events);
- entry.extend(batch.events);
- self.cancel_reasons.insert(batch.channel_id, reason);
+ let channel_id = batch.channel_id;
+ let conversation_root = batch.conversation_root;
+ let mut events = batch.cancelled_events;
+ events.extend(batch.events);
+ let batches = self.cancelled_batches.entry(channel_id).or_default();
+ if let Some(existing) = batches
+ .iter_mut()
+ .find(|existing| existing.conversation_root == conversation_root)
+ {
+ // Preserve any already-cancelled events for this same root. The most
+ // recent cancellation reason wins, matching prior double-cancel behavior.
+ existing.events.extend(events);
+ existing.reason = reason;
+ } else {
+ batches.push_back(CancelledBatch {
+ conversation_root,
+ events,
+ reason,
+ });
+ }
}
/// Returns `true` if any channel has pending events that are not in-flight
@@ -600,7 +687,6 @@ impl EventQueue {
self.retry_after.remove(&channel_id);
self.retry_counts.remove(&channel_id);
self.cancelled_batches.remove(&channel_id);
- self.cancel_reasons.remove(&channel_id);
self.withheld_native_steer.remove(&channel_id);
// Preserve in_flight_channels AND in_flight_deadlines: the in-flight
// task will eventually complete (calling mark_complete) or the deadline
@@ -949,6 +1035,12 @@ pub enum ConversationContext {
total: usize,
truncated: bool,
},
+ /// Recent channel roots for a fresh top-level session. Reply bodies are omitted.
+ Channel {
+ messages: Vec,
+ total: usize,
+ truncated: bool,
+ },
}
/// A single message in a conversation context section.
@@ -957,6 +1049,7 @@ pub struct ContextMessage {
pub pubkey: String,
pub timestamp: String,
pub content: String,
+ pub has_replies: bool,
}
/// Channel metadata for prompt formatting.
@@ -1298,6 +1391,11 @@ fn format_conversation_context(
total,
truncated,
} => ("Conversation Context", messages, total, truncated),
+ ConversationContext::Channel {
+ messages,
+ total,
+ truncated,
+ } => ("Channel Context", messages, total, truncated),
};
let trunc_label = if *truncated { ", truncated" } else { "" };
@@ -1313,6 +1411,9 @@ fn format_conversation_context(
msg.timestamp,
msg.content,
));
+ if msg.has_replies {
+ s.push_str(" [has thread replies]");
+ }
}
s
}
@@ -1613,6 +1714,7 @@ mod tests {
fn make_queued(channel_id: Uuid, content: &str) -> QueuedEvent {
QueuedEvent {
channel_id,
+ conversation_root: None,
event: make_event(content),
received_at: Instant::now(),
prompt_tag: "test".into(),
@@ -1623,6 +1725,7 @@ mod tests {
fn make_queued_at(channel_id: Uuid, content: &str, age: Duration) -> QueuedEvent {
QueuedEvent {
channel_id,
+ conversation_root: None,
event: make_event(content),
received_at: Instant::now() - age,
prompt_tag: "test".into(),
@@ -1643,6 +1746,7 @@ mod tests {
.unwrap();
QueuedEvent {
channel_id,
+ conversation_root: None,
event,
received_at: Instant::now(),
prompt_tag: "test".into(),
@@ -1667,6 +1771,71 @@ mod tests {
assert_eq!(base_section(" line1\nline2 "), "[Base]\n line1\nline2");
}
+ #[test]
+ fn test_distinct_conversation_roots_are_flushed_in_separate_channel_batches() {
+ let mut q = EventQueue::new(DedupMode::Queue);
+ let ch = Uuid::new_v4();
+ let mut first = make_queued(ch, "first root");
+ first.conversation_root = Some("root-a".into());
+ let mut second = make_queued(ch, "second root");
+ second.conversation_root = Some("root-b".into());
+ q.push(first);
+ q.push(second);
+
+ let first_batch = q.flush_next().expect("first root should flush");
+ assert_eq!(first_batch.conversation_root.as_deref(), Some("root-a"));
+ assert_eq!(first_batch.events.len(), 1);
+ assert_eq!(pending_count(&q), 1);
+
+ // Scheduling remains channel-serialized even though session identity is root-scoped.
+ assert!(q.flush_next().is_none());
+ q.mark_complete(ch);
+ let second_batch = q
+ .flush_next()
+ .expect("second root should flush after completion");
+ assert_eq!(second_batch.conversation_root.as_deref(), Some("root-b"));
+ assert_eq!(second_batch.events.len(), 1);
+ }
+
+ #[test]
+ fn test_cancelled_root_is_redispatched_before_different_queued_root() {
+ let mut q = EventQueue::new(DedupMode::Queue);
+ let ch = Uuid::new_v4();
+ let mut root_a = make_queued(ch, "cancelled A");
+ root_a.conversation_root = Some("root-a".into());
+ q.push(root_a);
+ let batch_a = q.flush_next().expect("root A should flush");
+ q.requeue_as_cancelled(batch_a, CancelReason::Steer);
+ q.mark_complete(ch);
+
+ let mut root_b = make_queued(ch, "queued B");
+ root_b.conversation_root = Some("root-b".into());
+ q.push(root_b);
+
+ let redispatched_a = q.flush_next().expect("cancelled A should redispatch first");
+ assert_eq!(redispatched_a.conversation_root.as_deref(), Some("root-a"));
+ assert_eq!(redispatched_a.events[0].event.content, "cancelled A");
+ assert!(redispatched_a.cancelled_events.is_empty());
+ q.mark_complete(ch);
+
+ let dispatched_b = q.flush_next().expect("root B should remain queued");
+ assert_eq!(dispatched_b.conversation_root.as_deref(), Some("root-b"));
+ assert_eq!(dispatched_b.events[0].event.content, "queued B");
+ assert!(dispatched_b.cancelled_events.is_empty());
+ }
+
+ #[test]
+ fn test_disabled_mode_events_without_roots_still_batch_by_channel() {
+ let mut q = EventQueue::new(DedupMode::Queue);
+ let ch = Uuid::new_v4();
+ q.push(make_queued(ch, "first"));
+ q.push(make_queued(ch, "second"));
+
+ let batch = q.flush_next().expect("channel batch should flush");
+ assert_eq!(batch.conversation_root, None);
+ assert_eq!(batch.events.len(), 2);
+ }
+
#[test]
fn test_push_flush_basic() {
let mut q = EventQueue::new(DedupMode::Queue);
@@ -1836,6 +2005,7 @@ mod tests {
.unwrap_or_else(|_| event.pubkey.to_hex());
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event,
@@ -1867,6 +2037,7 @@ mod tests {
let ch = Uuid::new_v4();
FlushBatch {
channel_id: ch,
+ conversation_root: None,
events: vec![BatchEvent {
event: make_event("the new message"),
prompt_tag: "@mention".into(),
@@ -1997,6 +2168,7 @@ mod tests {
// Multi-event header path must also branch on reason.
let ch = Uuid::new_v4();
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![
BatchEvent {
@@ -2054,6 +2226,7 @@ mod tests {
let _steering_id = steering.id.to_hex();
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event: steering,
@@ -2146,6 +2319,7 @@ mod tests {
let e3 = make_event("third message");
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![
BatchEvent {
@@ -2186,6 +2360,7 @@ mod tests {
let event = make_event("hello");
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event,
@@ -2209,6 +2384,7 @@ mod tests {
let ch = Uuid::new_v4();
let event = make_event("hi");
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event,
@@ -2241,6 +2417,7 @@ mod tests {
let ch = Uuid::new_v4();
let event = make_event("hi");
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event,
@@ -2271,6 +2448,7 @@ mod tests {
let ch = Uuid::new_v4();
let event = make_event("hi");
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event,
@@ -2298,6 +2476,7 @@ mod tests {
let event = make_event("hello");
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event,
@@ -2322,6 +2501,7 @@ mod tests {
let event = make_event("hello");
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event,
@@ -2378,6 +2558,7 @@ mod tests {
let event = make_event("hello");
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event,
@@ -2416,6 +2597,7 @@ mod tests {
let ch = Uuid::new_v4();
let event = make_event("hello");
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event,
@@ -2428,6 +2610,7 @@ mod tests {
let ctx = ConversationContext::Thread {
messages: vec![ContextMessage {
+ has_replies: false,
pubkey: "npub1test".into(),
content: "prior message".into(),
timestamp: "2024-01-01T00:00:00Z".into(),
@@ -2646,6 +2829,7 @@ mod tests {
let old_time = Instant::now() - Duration::from_secs(10);
q.push(QueuedEvent {
+ conversation_root: None,
channel_id: ch,
event: make_event("old-msg"),
received_at: old_time,
@@ -2933,6 +3117,7 @@ mod tests {
let ch = Uuid::new_v4();
let event = make_event("hello");
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event,
@@ -2964,6 +3149,7 @@ mod tests {
let ch = Uuid::new_v4();
let event = make_event("hey");
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event,
@@ -3002,6 +3188,7 @@ mod tests {
]],
);
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event,
@@ -3030,6 +3217,7 @@ mod tests {
]],
);
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event,
@@ -3042,11 +3230,13 @@ mod tests {
let ctx = ConversationContext::Thread {
messages: vec![
ContextMessage {
+ has_replies: false,
pubkey: "npub1xyz".into(),
timestamp: "2026-03-15T16:30:00Z".into(),
content: "Let's refactor auth".into(),
},
ContextMessage {
+ has_replies: false,
pubkey: "npub1def".into(),
timestamp: "2026-03-15T16:35:00Z".into(),
content: "yes go ahead".into(),
@@ -3074,6 +3264,7 @@ mod tests {
let ch = Uuid::new_v4();
let event = make_event("ok do that");
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event,
@@ -3089,6 +3280,7 @@ mod tests {
};
let ctx = ConversationContext::Dm {
messages: vec![ContextMessage {
+ has_replies: false,
pubkey: "npub1abc".into(),
timestamp: "2026-03-15T16:00:00Z".into(),
content: "Can you deploy?".into(),
@@ -3123,6 +3315,7 @@ mod tests {
);
let author_hex = event.pubkey.to_hex();
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event,
@@ -3134,6 +3327,7 @@ mod tests {
};
let ctx = ConversationContext::Thread {
messages: vec![ContextMessage {
+ has_replies: false,
pubkey: author_hex.clone(),
timestamp: "2026-03-25T05:51:25Z".into(),
content: "follow up".into(),
@@ -3330,6 +3524,7 @@ mod tests {
]],
);
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event,
@@ -3346,6 +3541,7 @@ mod tests {
// Thread context fetched (as the fetch path does for DM replies).
let ctx = ConversationContext::Thread {
messages: vec![ContextMessage {
+ has_replies: false,
pubkey: "npub1xyz".into(),
timestamp: "2026-03-15T16:30:00Z".into(),
content: "Should I deploy?".into(),
@@ -3387,6 +3583,7 @@ mod tests {
let ch = Uuid::new_v4();
let event = make_event("hey there");
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event,
@@ -3427,6 +3624,7 @@ mod tests {
let event = make_event("test");
let event_id = event.id.to_hex();
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event,
@@ -3451,6 +3649,7 @@ mod tests {
let hex = event.pubkey.to_hex();
let npub = event.pubkey.to_bech32().unwrap();
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event,
@@ -3474,6 +3673,7 @@ mod tests {
// Kind 9 (stream message) β tags were previously stripped.
let event = make_event_with_tags("hello", vec![vec!["h".into(), ch.to_string()]]);
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event,
@@ -3840,6 +4040,7 @@ mod tests {
vec![vec!["e".into(), root_id.clone(), "".into(), "reply".into()]],
);
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event,
@@ -3882,6 +4083,7 @@ mod tests {
);
let event_id = event.id.to_hex();
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event,
@@ -3916,6 +4118,7 @@ mod tests {
let event = make_event("hello world");
let event_id = event.id.to_hex();
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event,
@@ -3945,6 +4148,7 @@ mod tests {
let ch = Uuid::new_v4();
let event = make_event("hey there");
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event,
@@ -3987,6 +4191,7 @@ mod tests {
);
let event_id = event.id.to_hex();
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event,
@@ -4023,6 +4228,7 @@ mod tests {
vec![vec!["e".into(), root_id.clone(), "".into(), "reply".into()]],
);
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event,
@@ -4058,6 +4264,7 @@ mod tests {
vec![vec!["e".into(), root_id.clone(), "".into(), "reply".into()]],
);
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![
BatchEvent {
@@ -4095,6 +4302,7 @@ mod tests {
let plain = make_event("latest top-level");
let plain_id = plain.id.to_hex();
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![
BatchEvent {
@@ -4129,6 +4337,7 @@ mod tests {
fn make_single_batch(content: &str) -> FlushBatch {
FlushBatch {
channel_id: Uuid::new_v4(),
+ conversation_root: None,
events: vec![BatchEvent {
event: make_event(content),
prompt_tag: "test".into(),
@@ -4422,6 +4631,7 @@ mod tests {
let canvas = "[Channel Canvas]\nCanvas revision (event ID): abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234\nLast modified: 2024-01-15T10:30:00+00:00\nFetch current content with: buzz canvas get --channel 00f1ccaf-1506-4dd7-9a0e-fa67e9e486ae";
let ch = Uuid::new_v4();
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event: make_event("hi"),
@@ -4451,6 +4661,7 @@ mod tests {
let canvas = "[Channel Canvas]\nCanvas revision (event ID): abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234\nLast modified: 2024-01-15T10:30:00+00:00\nFetch current content with: buzz canvas get --channel 00f1ccaf-1506-4dd7-9a0e-fa67e9e486ae";
let ch = Uuid::new_v4();
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event: make_event("hi"),
@@ -4479,6 +4690,7 @@ mod tests {
fn test_format_prompt_no_canvas_produces_no_canvas_section() {
let ch = Uuid::new_v4();
let batch = FlushBatch {
+ conversation_root: None,
channel_id: ch,
events: vec![BatchEvent {
event: make_event("hi"),
diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs
index 67d01fd38d..4fec0bf3c0 100644
--- a/crates/buzz-acp/src/relay.rs
+++ b/crates/buzz-acp/src/relay.rs
@@ -353,6 +353,14 @@ impl RestClient {
/// Accepts a slice of `nostr::Filter` (serialized as JSON array).
/// Returns the events as a `serde_json::Value` (JSON array of event objects).
pub async fn query(&self, filters: &[nostr::Filter]) -> Result {
+ let value = serde_json::to_value(filters)
+ .map_err(|e| RelayError::Http(format!("filter serialize error: {e}")))?;
+ self.query_value(&value).await
+ }
+
+ /// Query using raw bridge filters, including Buzz-only extensions such as
+ /// `top_level` and `include_summaries` that `nostr::Filter` cannot express.
+ pub async fn query_value(&self, filters: &Value) -> Result {
let body_bytes = serde_json::to_vec(filters)
.map_err(|e| RelayError::Http(format!("filter serialize error: {e}")))?;
let resp = self.bridge_post("/query", &body_bytes).await?;
diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs
index 3d910c3133..bab9c94315 100644
--- a/desktop/src-tauri/src/app_state.rs
+++ b/desktop/src-tauri/src/app_state.rs
@@ -38,6 +38,8 @@ pub struct AppState {
/// records. Disabled by the agent-managed profiles experiment so an agent's
/// own profile updates are not overwritten on start or restore.
pub managed_agent_profile_reconcile_enabled: AtomicBool,
+ /// Process-local in-app experiment forwarded to newly spawned ACP harnesses.
+ pub acp_top_level_sessions_experiment: AtomicBool,
/// Shared shutdown signal checked by launch-time agent restoration.
pub shutdown_started: AtomicBool,
/// Serializes the restore spawn/register transition with shutdown cleanup,
@@ -201,6 +203,7 @@ pub fn build_app_state() -> AppState {
relay_url_override: Mutex::new(None),
managed_agent_restore_pending: AtomicBool::new(false),
managed_agent_profile_reconcile_enabled: AtomicBool::new(true),
+ acp_top_level_sessions_experiment: AtomicBool::new(false),
shutdown_started: AtomicBool::new(false),
managed_agent_restore_transition: Mutex::new(()),
identity_mutation: Mutex::new(()),
diff --git a/desktop/src-tauri/src/commands/experiments.rs b/desktop/src-tauri/src/commands/experiments.rs
new file mode 100644
index 0000000000..7014fd0f88
--- /dev/null
+++ b/desktop/src-tauri/src/commands/experiments.rs
@@ -0,0 +1,121 @@
+use std::{
+ fs,
+ path::{Path, PathBuf},
+ sync::atomic::Ordering,
+};
+
+use atomic_write_file::AtomicWriteFile;
+use serde::{Deserialize, Serialize};
+use tauri::{AppHandle, Manager, State};
+
+use crate::app_state::AppState;
+
+const EXPERIMENTS_FILE: &str = "desktop-experiments.json";
+
+#[derive(Debug, Default, Deserialize, Serialize)]
+#[serde(default)]
+struct DesktopExperiments {
+ acp_top_level_sessions: bool,
+}
+
+fn experiments_path(app: &AppHandle) -> Result {
+ Ok(app
+ .path()
+ .app_data_dir()
+ .map_err(|error| format!("app data dir: {error}"))?
+ .join(EXPERIMENTS_FILE))
+}
+
+fn load_experiments(path: &Path) -> Result {
+ if !path.exists() {
+ return Ok(DesktopExperiments::default());
+ }
+ let payload =
+ fs::read(path).map_err(|error| format!("failed to read {}: {error}", path.display()))?;
+ serde_json::from_slice(&payload)
+ .map_err(|error| format!("failed to parse {}: {error}", path.display()))
+}
+
+fn save_experiments(path: &Path, experiments: &DesktopExperiments) -> Result<(), String> {
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent)
+ .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
+ }
+ let payload = serde_json::to_vec_pretty(experiments)
+ .map_err(|error| format!("failed to serialize experiments: {error}"))?;
+ let mut file = AtomicWriteFile::open(path)
+ .map_err(|error| format!("open {} for atomic write: {error}", path.display()))?;
+ use std::io::Write;
+ file.write_all(&payload)
+ .map_err(|error| format!("write {}: {error}", path.display()))?;
+ file.commit()
+ .map_err(|error| format!("commit {}: {error}", path.display()))
+}
+
+/// Hydrate process state from the Rust-owned store before managed-agent restore.
+pub(crate) fn hydrate_desktop_experiments(app: &AppHandle, state: &AppState) -> Result<(), String> {
+ let experiments = load_experiments(&experiments_path(app)?)?;
+ state
+ .acp_top_level_sessions_experiment
+ .store(experiments.acp_top_level_sessions, Ordering::Release);
+ Ok(())
+}
+
+#[tauri::command]
+pub fn get_acp_top_level_sessions_experiment(state: State<'_, AppState>) -> bool {
+ state
+ .acp_top_level_sessions_experiment
+ .load(Ordering::Acquire)
+}
+
+/// Durably apply the experiment before exposing it to subsequently spawned agents.
+#[tauri::command]
+pub fn set_acp_top_level_sessions_experiment(
+ enabled: bool,
+ app: AppHandle,
+ state: State<'_, AppState>,
+) -> Result<(), String> {
+ let path = experiments_path(&app)?;
+ let mut experiments = load_experiments(&path)?;
+ experiments.acp_top_level_sessions = enabled;
+ save_experiments(&path, &experiments)?;
+ state
+ .acp_top_level_sessions_experiment
+ .store(enabled, Ordering::Release);
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{load_experiments, save_experiments, DesktopExperiments};
+
+ #[test]
+ fn missing_store_defaults_disabled() {
+ let dir = tempfile::tempdir().unwrap();
+ let loaded = load_experiments(&dir.path().join("missing.json")).unwrap();
+ assert!(!loaded.acp_top_level_sessions);
+ }
+
+ #[test]
+ fn persisted_enabled_state_round_trips_for_fresh_launch() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("desktop-experiments.json");
+ save_experiments(
+ &path,
+ &DesktopExperiments {
+ acp_top_level_sessions: true,
+ },
+ )
+ .unwrap();
+ let loaded = load_experiments(&path).unwrap();
+ assert!(loaded.acp_top_level_sessions);
+ }
+
+ #[test]
+ fn malformed_store_fails_closed_instead_of_enabling() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("desktop-experiments.json");
+ std::fs::write(&path, b"not json").unwrap();
+ assert!(load_experiments(&path).is_err());
+ }
+}
diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs
index eaa608c339..9d0987f949 100644
--- a/desktop/src-tauri/src/commands/mod.rs
+++ b/desktop/src-tauri/src/commands/mod.rs
@@ -15,6 +15,7 @@ mod channels;
mod clipboard;
mod dms;
mod engrams;
+pub(crate) mod experiments;
mod export_util;
mod global_agent_config;
mod identity;
@@ -65,6 +66,7 @@ pub use channels::*;
pub use clipboard::*;
pub use dms::*;
pub use engrams::*;
+pub use experiments::*;
pub use global_agent_config::*;
pub use identity::*;
pub use identity_archive::*;
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index fe9b39899a..cf98222727 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -490,6 +490,16 @@ pub fn run() {
migration::run_boot_migrations(&app_handle);
}
+ // Hydrate Rust-owned experiments before any launch-time managed-agent
+ // restore can spawn a process. A corrupt store fails closed and leaves
+ // the disabled default in place.
+ let state = app_handle.state::();
+ if let Err(error) =
+ commands::experiments::hydrate_desktop_experiments(&app_handle, &state)
+ {
+ eprintln!("buzz-desktop: failed to hydrate desktop experiments: {error}");
+ }
+
// Resolve persisted identity key (env var β file β generate+save).
// This is fatal β the app should not start with an ephemeral identity
// that will be lost on restart, as that silently breaks channel
@@ -846,6 +856,8 @@ pub fn run() {
list_managed_agents,
create_managed_agent,
start_managed_agent,
+ get_acp_top_level_sessions_experiment,
+ set_acp_top_level_sessions_experiment,
stop_managed_agent,
set_agent_managed_profiles,
set_managed_agent_start_on_app_launch,
diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs
index 31b31e00d0..264d9241d0 100644
--- a/desktop/src-tauri/src/managed_agents/runtime.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime.rs
@@ -1711,7 +1711,16 @@ pub fn spawn_agent_child(
.unwrap_or(super::types::DEFAULT_AGENT_MAX_TURN_DURATION_SECONDS);
command.env("BUZZ_ACP_MAX_TURN_DURATION", max_dur.to_string());
command.env("BUZZ_ACP_AGENTS", record.parallelism.to_string());
+ // Legacy default. User env may override this while the experiment is off;
+ // enabled experiment state is authoritatively finalized at the spawn boundary.
command.env("BUZZ_ACP_MULTIPLE_EVENT_HANDLING", "steer");
+ let top_level_sessions = {
+ use std::sync::atomic::Ordering;
+ use tauri::Manager;
+ app.state::()
+ .acp_top_level_sessions_experiment
+ .load(Ordering::Acquire)
+ };
command.env("BUZZ_ACP_DEDUP", "queue");
if let Some(meta) = runtime_meta {
for (key, value) in meta.default_env {
@@ -1865,6 +1874,11 @@ pub fn spawn_agent_child(
// env_clear().
command.env("BUZZ_MANAGED_AGENT", current_instance_id(app));
+ // Finalize after every default and user env write. Enabled experiment state
+ // must be authoritative; disabled state only removes the new variable and
+ // preserves the legacy handling override chosen above or supplied by users.
+ finalize_acp_top_level_sessions_env(&mut command, top_level_sessions);
+
// Spawn the harness in its own process group so we can kill the entire
// tree (harness + MCP servers + agent subprocesses) on shutdown.
#[cfg(unix)]
@@ -2120,5 +2134,18 @@ pub(crate) fn resolve_effective_prompt_model_provider(
}
}
+fn finalize_acp_top_level_sessions_env(command: &mut std::process::Command, enabled: bool) {
+ if enabled {
+ command.env("BUZZ_ACP_TOP_LEVEL_SESSIONS", "true");
+ // Conversation roots remain channel-serialized but must never steer
+ // into another root's in-flight ACP session.
+ command.env("BUZZ_ACP_MULTIPLE_EVENT_HANDLING", "queue");
+ } else {
+ // The experiment's variable is never user-controlled while disabled.
+ // Do not touch MULTIPLE_EVENT_HANDLING: legacy user overrides remain valid.
+ command.env_remove("BUZZ_ACP_TOP_LEVEL_SESSIONS");
+ }
+}
+
#[cfg(test)]
mod tests;
diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs
index 06d3ac6db3..8d395acaab 100644
--- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs
@@ -760,3 +760,50 @@ fn own_group_grandchild_detected_by_ancestor_walk() {
unsafe { libc::kill(-(intermediate_pid as i32), libc::SIGKILL) };
let _ = intermediate.wait();
}
+
+fn command_env(command: &std::process::Command) -> std::collections::HashMap<
+ std::ffi::OsString,
+ std::ffi::OsString,
+> {
+ command
+ .get_envs()
+ .filter_map(|(key, value)| value.map(|value| (key.to_owned(), value.to_owned())))
+ .collect()
+}
+
+#[test]
+fn top_level_sessions_enabled_finalization_overrides_conflicting_later_env() {
+ let mut command = std::process::Command::new("buzz-acp");
+ // Simulate conflicting runtime defaults/user env written after Buzz's legacy defaults.
+ command.env("BUZZ_ACP_TOP_LEVEL_SESSIONS", "false");
+ command.env("BUZZ_ACP_MULTIPLE_EVENT_HANDLING", "steer");
+ super::finalize_acp_top_level_sessions_env(&mut command, true);
+ let env = command_env(&command);
+ assert_eq!(
+ env.get(std::ffi::OsStr::new("BUZZ_ACP_TOP_LEVEL_SESSIONS"))
+ .unwrap(),
+ "true"
+ );
+ assert_eq!(
+ env.get(std::ffi::OsStr::new("BUZZ_ACP_MULTIPLE_EVENT_HANDLING"))
+ .unwrap(),
+ "queue"
+ );
+}
+
+#[test]
+fn top_level_sessions_disabled_removes_flag_but_preserves_handling_override() {
+ let mut command = std::process::Command::new("buzz-acp");
+ // Simulate conflicting later user env: the new flag is suppressed while the
+ // pre-existing handling override remains compatible when experiment is off.
+ command.env("BUZZ_ACP_TOP_LEVEL_SESSIONS", "true");
+ command.env("BUZZ_ACP_MULTIPLE_EVENT_HANDLING", "interrupt");
+ super::finalize_acp_top_level_sessions_env(&mut command, false);
+ let env = command_env(&command);
+ assert!(!env.contains_key(std::ffi::OsStr::new("BUZZ_ACP_TOP_LEVEL_SESSIONS")));
+ assert_eq!(
+ env.get(std::ffi::OsStr::new("BUZZ_ACP_MULTIPLE_EVENT_HANDLING"))
+ .unwrap(),
+ "interrupt"
+ );
+}
diff --git a/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx b/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx
index 5476f151d0..16e2e87efd 100644
--- a/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx
+++ b/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx
@@ -1,13 +1,83 @@
import { setAgentManagedProfiles } from "@/shared/api/tauri";
import { desktopFeatures, useFeatureToggle } from "@/shared/features";
+import { useEffect, useState } from "react";
import type { FeatureDefinition } from "@/shared/features";
+import { listManagedAgents } from "@/shared/api/tauri";
+import {
+ startManagedAgent,
+ stopManagedAgent,
+} from "@/shared/api/tauriManagedAgents";
+import { invokeTauri } from "@/shared/api/tauri";
import { Switch } from "@/shared/ui/switch";
import { SettingsSectionHeader } from "./SettingsSectionHeader";
+import { applyAcpTopLevelSessionsExperiment } from "./acpTopLevelSessionsExperiment";
function FeatureRow({ feature }: { feature: FeatureDefinition }) {
const [enabled, toggle] = useFeatureToggle(feature.id);
+ const [pending, setPending] = useState(false);
const switchId = `feature-toggle-${feature.id}`;
+ // Rust persistence is authoritative for this runtime experiment. Hydrate the
+ // local feature store when the row mounts rather than pushing localStorage
+ // into Tauri after launch-time agent restore has already run. Keep the switch
+ // disabled until this one-shot hydration finishes so a stale local value
+ // cannot race an in-flight toggle.
+ const isAcpTopLevelSessions = feature.id === "acpTopLevelSessions";
+ const [hydrated, setHydrated] = useState(!isAcpTopLevelSessions);
+ useEffect(() => {
+ if (!isAcpTopLevelSessions) return;
+ let cancelled = false;
+ void invokeTauri("get_acp_top_level_sessions_experiment")
+ .then((persisted) => {
+ if (cancelled) return;
+ toggle(persisted);
+ setHydrated(true);
+ })
+ .catch((error) => {
+ if (!cancelled) {
+ console.error(
+ "Failed to hydrate ACP top-level sessions experiment",
+ error,
+ );
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [isAcpTopLevelSessions, toggle]);
+
+ const handleToggle = async (value: boolean) => {
+ if (feature.id !== "acpTopLevelSessions") {
+ toggle(value);
+ if (feature.id === "agentManagedProfiles") {
+ void setAgentManagedProfiles(value).catch((error) => {
+ console.error(
+ "Failed to apply agent-managed profiles setting:",
+ error,
+ );
+ });
+ }
+ return;
+ }
+ setPending(true);
+ try {
+ await applyAcpTopLevelSessionsExperiment(enabled, value, {
+ setBackend: (next) =>
+ invokeTauri("set_acp_top_level_sessions_experiment", {
+ enabled: next,
+ }),
+ listAgents: listManagedAgents,
+ stopAgent: stopManagedAgent,
+ startAgent: startManagedAgent,
+ setUi: toggle,
+ });
+ } catch (error) {
+ console.error("Failed to apply ACP top-level sessions experiment", error);
+ } finally {
+ setPending(false);
+ }
+ };
+
return (
@@ -20,17 +90,8 @@ function FeatureRow({ feature }: { feature: FeatureDefinition }) {
aria-labelledby={`${switchId}-label`}
checked={enabled}
data-testid={switchId}
- onCheckedChange={(value) => {
- toggle(value);
- if (feature.id === "agentManagedProfiles") {
- void setAgentManagedProfiles(value).catch((error) => {
- console.error(
- "Failed to apply agent-managed profiles setting:",
- error,
- );
- });
- }
- }}
+ disabled={pending || !hydrated}
+ onCheckedChange={(value) => void handleToggle(value)}
/>
);
diff --git a/desktop/src/features/settings/ui/acpTopLevelSessionsExperiment.test.mjs b/desktop/src/features/settings/ui/acpTopLevelSessionsExperiment.test.mjs
new file mode 100644
index 0000000000..1246206cc0
--- /dev/null
+++ b/desktop/src/features/settings/ui/acpTopLevelSessionsExperiment.test.mjs
@@ -0,0 +1,133 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import { applyAcpTopLevelSessionsExperiment } from "./acpTopLevelSessionsExperiment.ts";
+
+const localRunning = {
+ pubkey: "local",
+ status: "running",
+ backend: { type: "local" },
+};
+const remoteRunning = {
+ pubkey: "remote",
+ status: "running",
+ backend: { type: "remote" },
+};
+const localStopped = {
+ pubkey: "stopped",
+ status: "stopped",
+ backend: { type: "local" },
+};
+
+function harness(overrides = {}) {
+ const calls = [];
+ return {
+ calls,
+ deps: {
+ setBackend: async (enabled) => calls.push(["backend", enabled]),
+ listAgents: async () => [localRunning, remoteRunning, localStopped],
+ stopAgent: async (pubkey) => calls.push(["stop", pubkey]),
+ startAgent: async (pubkey) => calls.push(["start", pubkey]),
+ setUi: (enabled) => calls.push(["ui", enabled]),
+ ...overrides,
+ },
+ };
+}
+
+describe("ACP top-level sessions experiment", () => {
+ it("commits UI only after applying backend state and restarting running local agents", async () => {
+ const { calls, deps } = harness();
+ await applyAcpTopLevelSessionsExperiment(false, true, deps);
+ assert.deepEqual(calls, [
+ ["backend", true],
+ ["stop", "local"],
+ ["start", "local"],
+ ["ui", true],
+ ]);
+ });
+
+ it("rolls backend, processes, and UI back when restart fails", async () => {
+ let starts = 0;
+ const { calls, deps } = harness({
+ startAgent: async (pubkey) => {
+ calls.push(["start", pubkey]);
+ starts += 1;
+ if (starts === 1) throw new Error("restart failed");
+ },
+ });
+ await assert.rejects(
+ applyAcpTopLevelSessionsExperiment(false, true, deps),
+ /restart failed/,
+ );
+ assert.deepEqual(calls, [
+ ["backend", true],
+ ["stop", "local"],
+ ["start", "local"],
+ ["backend", false],
+ ["stop", "local"],
+ ["start", "local"],
+ ["ui", false],
+ ]);
+ });
+
+ it("rolls UI back when persistence fails before any restart", async () => {
+ const { calls, deps } = harness({
+ setBackend: async (enabled) => {
+ calls.push(["backend", enabled]);
+ if (enabled) throw new Error("persist failed");
+ },
+ });
+ await assert.rejects(
+ applyAcpTopLevelSessionsExperiment(false, true, deps),
+ /persist failed/,
+ );
+ assert.equal(calls.at(-1)[0], "ui");
+ assert.equal(calls.at(-1)[1], false);
+ });
+
+ it("attempts every process rollback even when one rollback restart fails", async () => {
+ const first = {
+ pubkey: "first",
+ status: "running",
+ backend: { type: "local" },
+ };
+ const second = {
+ pubkey: "second",
+ status: "running",
+ backend: { type: "local" },
+ };
+ let firstStarts = 0;
+ let secondStarts = 0;
+ const { calls, deps } = harness({
+ listAgents: async () => [first, second],
+ startAgent: async (pubkey) => {
+ calls.push(["start", pubkey]);
+ if (pubkey === "first") {
+ firstStarts += 1;
+ if (firstStarts === 2) throw new Error("first rollback failed");
+ } else {
+ secondStarts += 1;
+ if (secondStarts === 1) throw new Error("apply failed");
+ }
+ },
+ });
+
+ await assert.rejects(
+ applyAcpTopLevelSessionsExperiment(false, true, deps),
+ /apply failed/,
+ );
+ assert.deepEqual(calls, [
+ ["backend", true],
+ ["stop", "first"],
+ ["start", "first"],
+ ["stop", "second"],
+ ["start", "second"],
+ ["backend", false],
+ ["stop", "first"],
+ ["start", "first"],
+ ["stop", "second"],
+ ["start", "second"],
+ ["ui", false],
+ ]);
+ assert.equal(secondStarts, 2);
+ });
+});
diff --git a/desktop/src/features/settings/ui/acpTopLevelSessionsExperiment.ts b/desktop/src/features/settings/ui/acpTopLevelSessionsExperiment.ts
new file mode 100644
index 0000000000..b7652c31ad
--- /dev/null
+++ b/desktop/src/features/settings/ui/acpTopLevelSessionsExperiment.ts
@@ -0,0 +1,66 @@
+export type ExperimentAgent = {
+ pubkey: string;
+ status: string;
+ backend: { type: string };
+};
+
+export type ExperimentToggleDependencies = {
+ setBackend: (enabled: boolean) => Promise
;
+ listAgents: () => Promise;
+ stopAgent: (pubkey: string) => Promise;
+ startAgent: (pubkey: string) => Promise;
+ setUi: (enabled: boolean) => void;
+};
+
+async function restartRunningLocalAgents(
+ agents: ExperimentAgent[],
+ deps: ExperimentToggleDependencies,
+): Promise {
+ for (const agent of agents) {
+ if (agent.status !== "running" || agent.backend.type !== "local") continue;
+ await deps.stopAgent(agent.pubkey);
+ await deps.startAgent(agent.pubkey);
+ }
+}
+
+/**
+ * Apply the Rust-owned experiment and restart affected processes. The UI is
+ * committed only after every restart succeeds. On failure, both persisted
+ * backend state and already-restarted agents are restored best-effort.
+ */
+export async function applyAcpTopLevelSessionsExperiment(
+ previous: boolean,
+ next: boolean,
+ deps: ExperimentToggleDependencies,
+): Promise {
+ const agents = await deps.listAgents();
+ try {
+ await deps.setBackend(next);
+ await restartRunningLocalAgents(agents, deps);
+ deps.setUi(next);
+ } catch (error) {
+ try {
+ await deps.setBackend(previous);
+ } catch (rollbackError) {
+ console.error(
+ "Failed to roll back ACP top-level sessions backend state",
+ rollbackError,
+ );
+ }
+ for (const agent of agents) {
+ if (agent.status !== "running" || agent.backend.type !== "local")
+ continue;
+ try {
+ await deps.stopAgent(agent.pubkey);
+ await deps.startAgent(agent.pubkey);
+ } catch (rollbackError) {
+ console.error(
+ `Failed to roll back ACP experiment process ${agent.pubkey}`,
+ rollbackError,
+ );
+ }
+ }
+ deps.setUi(previous);
+ throw error;
+ }
+}
diff --git a/preview-features.json b/preview-features.json
index ad8090d5ad..43d0035ef5 100644
--- a/preview-features.json
+++ b/preview-features.json
@@ -40,6 +40,14 @@
"platforms": [
"desktop"
]
+ },
+ {
+ "id": "acpTopLevelSessions",
+ "name": "Fresh agent sessions by conversation",
+ "description": "Start each human top-level agent mention in an isolated ACP session",
+ "platforms": [
+ "desktop"
+ ]
}
]
}
From f442707444a03b92af8879173ca7a8facb422cfb Mon Sep 17 00:00:00 2001
From: npub1zurdm6fx3kksz8f8d8y4js3mvjyu3rshlpqejr8wa8hta5tsqqeshmes7w
<1706dde9268dad011d2769c959423b6489c88e17f841990ceee9eebed1700033@sprout-oss.stage.blox.sqprod.co>
Date: Sat, 18 Jul 2026 11:51:36 -0700
Subject: [PATCH 02/11] refactor(acp): consolidate top-level-sessions
experiment maintenance surfaces
Reduce the ongoing cost of the top-level-sessions experiment by
collapsing its scattered maintenance surfaces without changing behavior:
- pool: replace eager session_owners reconciliation (5 lifecycle call
sites + retains in return_agent / invalidate_channel_sessions /
model-switch paths) with a single lazy prune_session_owners() at the
one read site in try_claim. Future slot-lifecycle paths can no longer
forget the purge; the invariant converges at one choke point.
- queue: extract pop_cancelled() / dispatch_cancelled() helpers so the
three copy-pasted cancelled-batch blocks in flush_next become
one-liners.
- session key: drop the top_level_sessions bool param and dead
thread-tag fallback from conversation_session_key; remove the
redundant PromptContext.top_level_sessions field. The key is now
purely data-driven from batch.conversation_root, which is only
populated at queue-push when the experiment is enabled.
Net -59 lines. cargo test -p buzz-acp: 530 passed; clippy and fmt
clean. Disabled-mode paths degenerate identically (root=None
everywhere).
Co-authored-by: npub1zurdm6fx3kksz8f8d8y4js3mvjyu3rshlpqejr8wa8hta5tsqqeshmes7w <1706dde9268dad011d2769c959423b6489c88e17f841990ceee9eebed1700033@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1zurdm6fx3kksz8f8d8y4js3mvjyu3rshlpqejr8wa8hta5tsqqeshmes7w <1706dde9268dad011d2769c959423b6489c88e17f841990ceee9eebed1700033@sprout-oss.stage.blox.sqprod.co>
---
crates/buzz-acp/src/lib.rs | 16 +++--
crates/buzz-acp/src/pool.rs | 109 +++++++++++++----------------------
crates/buzz-acp/src/queue.rs | 108 +++++++++++++---------------------
3 files changed, 87 insertions(+), 146 deletions(-)
diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs
index 7f94ede7bb..f291c0edd6 100644
--- a/crates/buzz-acp/src/lib.rs
+++ b/crates/buzz-acp/src/lib.rs
@@ -1475,7 +1475,6 @@ async fn tokio_main() -> Result<()> {
.as_deref()
.and_then(|hex| nostr::PublicKey::from_hex(hex).ok()),
memory_enabled: config.memory_enabled,
- top_level_sessions: config.top_level_sessions,
harness_name: crate::config::normalize_agent_command_identity(&config.agent_command),
});
@@ -1633,7 +1632,6 @@ async fn tokio_main() -> Result<()> {
if !slot.can_refill() {
continue;
}
- pool.clear_slot_session_owners(idx);
slot.respawn_in_flight = true;
tracing::info!(agent = idx, "slot refill: spawning background respawn");
let cmd = config.agent_command.clone();
@@ -2670,7 +2668,7 @@ fn dispatch_pending(
.last()
.map(|event| queue::parse_thread_tags(&event.event))
.unwrap_or_default();
- let session_key = pool::conversation_session_key(&batch, ctx.top_level_sessions);
+ let session_key = pool::conversation_session_key(&batch);
let affinity_hit = pool.has_session_for(&session_key);
let mut agent = match pool.try_claim(Some(&session_key)) {
Some(a) => a,
@@ -2953,7 +2951,6 @@ fn handle_prompt_result(
emit_turn_error(&death_message, None);
let index = result.agent.index;
- pool.clear_slot_session_owners(index);
let slot_history = &mut crash_history[index];
if !spawn_respawn_task(
result.agent,
@@ -2994,7 +2991,6 @@ fn handle_prompt_result(
emit_turn_error(&death_message, None);
let index = result.agent.index;
- pool.clear_slot_session_owners(index);
let slot_history = &mut crash_history[index];
if !spawn_respawn_task(
result.agent,
@@ -3060,7 +3056,6 @@ fn handle_prompt_result(
emit_turn_error(&e.to_string(), error_code);
let index = result.agent.index;
- pool.clear_slot_session_owners(index);
let slot_history = &mut crash_history[index];
if !spawn_respawn_task(
result.agent,
@@ -3112,7 +3107,6 @@ fn recover_panicked_agent(
return;
};
let i = meta.agent_index;
- pool.clear_slot_session_owners(i);
// Requeue BEFORE mark_complete (same rationale as handle_prompt_result).
if let Some(batch) = meta.recoverable_batch {
@@ -4502,8 +4496,12 @@ mod error_outcome_emission_tests {
pool.return_agent(owner);
}
pool.agents_mut()[0] = None;
- assert_eq!(pool.clear_slot_session_owners(0), 2);
- assert_eq!(pool.try_claim(Some(&keys[0])).unwrap().index, 1);
+ // Every reservation owned by the dead slot is pruned lazily on the
+ // next claim; both roots recreate on the surviving slot.
+ let first_claim = pool.try_claim(Some(&keys[0])).unwrap();
+ assert_eq!(first_claim.index, 1);
+ pool.return_agent(first_claim);
+ assert_eq!(pool.try_claim(Some(&keys[1])).unwrap().index, 1);
}
#[tokio::test]
diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs
index 734b8c58cb..25c41a351d 100644
--- a/crates/buzz-acp/src/pool.rs
+++ b/crates/buzz-acp/src/pool.rs
@@ -555,8 +555,6 @@ pub struct PromptContext {
/// `[Agent Memory β core]` section. On by default; disabled via
/// `--no-memory` / `BUZZ_ACP_NO_MEMORY`.
pub memory_enabled: bool,
- /// Whether ACP sessions are isolated by human conversation root.
- pub top_level_sessions: bool,
/// Harness identity string for NIP-AM `harness` field. Derived from the
/// configured `agent_command` at startup (e.g. `"goose"`, `"buzz-agent"`).
pub harness_name: String,
@@ -595,20 +593,11 @@ impl AgentPool {
// wait rather than creating a duplicate provider session in another slot.
if let Some(key) = session_key {
if key.root_event_id.is_some() {
+ self.prune_session_owners();
if let Some(&owner) = self.session_owners.get(key) {
- let owner_busy = self.task_map.values().any(|meta| meta.agent_index == owner);
- let owner_retains_session = self
- .agents
- .get(owner)
- .and_then(Option::as_ref)
- .is_some_and(|agent| agent.state.contains_session(key));
- if owner_busy || owner_retains_session {
- return self.agents.get_mut(owner).and_then(Option::take);
- }
- // The slot died, was replaced, or evicted/invalidated this
- // root while idle. Drop the stale reservation so a live
- // slot can recreate the provider session.
- self.session_owners.remove(key);
+ // Surviving the prune means the owner is busy (wait by
+ // returning None) or idle and retaining the session.
+ return self.agents.get_mut(owner).and_then(Option::take);
}
}
let idx = self.agents.iter().position(|slot| {
@@ -631,6 +620,23 @@ impl AgentPool {
self.agents[idx].take()
}
+ /// Drop root reservations that no longer bind: the owning slot is neither
+ /// busy (checked out on a task) nor idle-retaining the session. Runs before
+ /// every reservation read, so slot death, replacement, LRU eviction, and
+ /// channel invalidation all converge here instead of requiring explicit
+ /// cleanup calls at each lifecycle site.
+ fn prune_session_owners(&mut self) {
+ let agents = &self.agents;
+ let task_map = &self.task_map;
+ self.session_owners.retain(|key, owner| {
+ task_map.values().any(|meta| meta.agent_index == *owner)
+ || agents
+ .get(*owner)
+ .and_then(Option::as_ref)
+ .is_some_and(|agent| agent.state.contains_session(key))
+ });
+ }
+
/// Return an agent to its slot after a task completes.
pub fn return_agent(&mut self, agent: OwnedAgent) {
let idx = agent.index;
@@ -644,11 +650,6 @@ impl AgentPool {
"BUG: return_agent called for slot {idx} which is already occupied β overwriting"
);
}
- // Reconcile reservations whenever a slot returns. This clears roots
- // evicted by the per-slot LRU, explicitly invalidated while checked
- // out, or lost when a dead slot is replaced.
- self.session_owners
- .retain(|key, owner| *owner != idx || agent.state.contains_session(key));
self.agents[idx] = Some(agent);
}
@@ -759,14 +760,6 @@ impl AgentPool {
&mut self.agents
}
- /// Remove every root reservation owned by a slot that died or is about to
- /// be replaced. Returns the number removed for diagnostics and tests.
- pub fn clear_slot_session_owners(&mut self, index: usize) -> usize {
- let before = self.session_owners.len();
- self.session_owners.retain(|_, owner| *owner != index);
- before - self.session_owners.len()
- }
-
/// Remove the session for `channel_id` from all idle agents.
///
/// Called when the agent is removed from a channel β stale sessions
@@ -784,8 +777,8 @@ impl AgentPool {
}
}
}
- self.session_owners
- .retain(|key, _| key.channel_id != channel_id);
+ // Freed root reservations are dropped lazily by prune_session_owners
+ // on the next root claim.
count
}
@@ -841,33 +834,24 @@ impl AgentPool {
agent.model_overridden = true;
agent.state.invalidate_channel(&channel_id);
}
- self.session_owners
- .retain(|key, _| key.channel_id != channel_id);
+ // Freed reservations are dropped lazily by prune_session_owners on the
+ // next root claim.
IdleSwitchResult::Switched
}
/// Apply a busy channel model switch to every currently idle slot retaining
/// another root for that channel. The checked-out owner receives the
- /// control signal separately and invalidates its own channel state.
+ /// control signal separately and invalidates its own channel state; its
+ /// reservation survives pruning (busy), so a reply arriving during
+ /// cancellation still waits for it instead of falling through.
pub fn switch_other_idle_channel_roots(&mut self, channel_id: Uuid, model_id: &str) {
- let mut invalidated_slots = Vec::new();
- for (index, agent) in self.agents.iter_mut().enumerate() {
- let Some(agent) = agent.as_mut() else {
- continue;
- };
+ for agent in self.agents.iter_mut().flatten() {
if agent.state.has_channel_state(&channel_id) {
agent.desired_model = Some(model_id.to_string());
agent.model_overridden = true;
agent.state.invalidate_channel(&channel_id);
- invalidated_slots.push(index);
}
}
- // Preserve the checked-out root owner's reservation until that task
- // returns with its own channel state invalidated. Otherwise a reply
- // arriving during cancellation could fall through to an idle slot.
- self.session_owners.retain(|key, owner| {
- key.channel_id != channel_id || !invalidated_slots.contains(owner)
- });
}
}
@@ -1356,24 +1340,13 @@ fn send_prompt_result(
});
}
-pub(crate) fn conversation_session_key(
- batch: &FlushBatch,
- top_level_sessions: bool,
-) -> ConversationSessionKey {
- let root_event_id = top_level_sessions
- .then(|| {
- batch.conversation_root.clone().or_else(|| {
- batch.events.last().map(|event| {
- crate::queue::parse_thread_tags(&event.event)
- .root_event_id
- .unwrap_or_else(|| event.event.id.to_hex())
- })
- })
- })
- .flatten();
+/// Session identity for a batch. `FlushBatch::conversation_root` is populated
+/// at queue-push time only when the top-level-sessions experiment is enabled,
+/// so this is purely data-driven: no root means the legacy channel-scoped key.
+pub(crate) fn conversation_session_key(batch: &FlushBatch) -> ConversationSessionKey {
ConversationSessionKey {
channel_id: batch.channel_id,
- root_event_id,
+ root_event_id: batch.conversation_root.clone(),
}
}
@@ -1400,7 +1373,7 @@ pub async fn run_prompt_task(
) {
// Is this a channel prompt or a heartbeat?
let source = match &batch {
- Some(b) => PromptSource::Channel(conversation_session_key(b, ctx.top_level_sessions)),
+ Some(b) => PromptSource::Channel(conversation_session_key(b)),
None => PromptSource::Heartbeat,
};
let observer_channel_id = match &source {
@@ -2679,7 +2652,8 @@ async fn fetch_conversation_context(
// Experiment: seed only a fresh top-level conversation. Replies use their
// thread context, and existing sessions already carry their own history.
- if ctx.top_level_sessions && is_new_session {
+ // `conversation_root` is only ever set when the experiment is enabled.
+ if batch.conversation_root.is_some() && is_new_session {
let triggering_ids: HashSet = batch
.events
.iter()
@@ -4581,18 +4555,18 @@ mod tests {
}
#[test]
- fn test_conversation_session_key_is_channel_scoped_when_disabled() {
+ fn test_conversation_session_key_is_channel_scoped_without_root() {
let batch = one_event_batch(Uuid::new_v4());
- let key = conversation_session_key(&batch, false);
+ let key = conversation_session_key(&batch);
assert_eq!(key.channel_id, batch.channel_id);
assert_eq!(key.root_event_id, None);
}
#[test]
- fn test_conversation_session_key_uses_explicit_root_when_enabled() {
+ fn test_conversation_session_key_uses_batch_root_when_present() {
let mut batch = one_event_batch(Uuid::new_v4());
batch.conversation_root = Some("root-a".into());
- let key = conversation_session_key(&batch, true);
+ let key = conversation_session_key(&batch);
assert_eq!(key.channel_id, batch.channel_id);
assert_eq!(key.root_event_id.as_deref(), Some("root-a"));
}
@@ -5827,7 +5801,6 @@ mod tests {
agent_owner_pubkey: owner_pubkey,
memory_enabled: false,
harness_name: "goose".to_string(),
- top_level_sessions: false,
}
}
diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs
index 8e71b29522..a07ce5278a 100644
--- a/crates/buzz-acp/src/queue.rs
+++ b/crates/buzz-acp/src/queue.rs
@@ -293,40 +293,13 @@ impl EventQueue {
.keys()
.find(|id| !self.in_flight_channels.contains(id))
.copied();
- match cancelled_id {
- Some(id) => {
- // No new events to merge β re-dispatch the oldest cancelled
- // batch unchanged under its original conversation root.
- let cancelled = self
- .cancelled_batches
- .get_mut(&id)
- .and_then(VecDeque::pop_front)
- .expect("cancelled channel must have a batch");
- if self
- .cancelled_batches
- .get(&id)
- .is_some_and(VecDeque::is_empty)
- {
- self.cancelled_batches.remove(&id);
- }
- self.in_flight_channels.insert(id);
- self.in_flight_deadlines
- .insert(id, now + self.in_flight_deadline);
- self.in_flight_batch_sizes
- .insert(id, cancelled.events.len());
- return Some(FlushBatch {
- channel_id: id,
- conversation_root: cancelled.conversation_root,
- events: cancelled.events,
- cancelled_events: vec![],
- cancel_reason: Some(cancelled.reason),
- });
- }
- None => return None,
- }
+ return cancelled_id.map(|id| self.dispatch_cancelled(id, now));
}
};
+ // Re-dispatch a cancelled batch whose conversation root differs from
+ // the queued head before starting the queued work β its root was
+ // interrupted first and must not merge into another root's turn.
let queued_root = self
.queues
.get(&channel_id)
@@ -336,32 +309,9 @@ impl EventQueue {
.cancelled_batches
.get(&channel_id)
.and_then(|batches| batches.front())
- .and_then(|batch| batch.conversation_root.clone());
- if self.cancelled_batches.contains_key(&channel_id) && cancelled_root != queued_root {
- let cancelled = self
- .cancelled_batches
- .get_mut(&channel_id)
- .and_then(VecDeque::pop_front)
- .expect("cancelled channel must have a batch");
- if self
- .cancelled_batches
- .get(&channel_id)
- .is_some_and(VecDeque::is_empty)
- {
- self.cancelled_batches.remove(&channel_id);
- }
- self.in_flight_channels.insert(channel_id);
- self.in_flight_deadlines
- .insert(channel_id, now + self.in_flight_deadline);
- self.in_flight_batch_sizes
- .insert(channel_id, cancelled.events.len());
- return Some(FlushBatch {
- channel_id,
- conversation_root: cancelled.conversation_root,
- events: cancelled.events,
- cancelled_events: vec![],
- cancel_reason: Some(cancelled.reason),
- });
+ .map(|batch| batch.conversation_root.clone());
+ if cancelled_root.is_some_and(|root| root != queued_root) {
+ return Some(self.dispatch_cancelled(channel_id, now));
}
// Drain up to MAX_BATCH_EVENTS; leave any remainder in the queue.
@@ -401,18 +351,7 @@ impl EventQueue {
self.in_flight_batch_sizes.insert(channel_id, events.len());
// Merge only a cancelled batch for the same conversation root.
- let cancelled = self
- .cancelled_batches
- .get_mut(&channel_id)
- .and_then(VecDeque::pop_front);
- if self
- .cancelled_batches
- .get(&channel_id)
- .is_some_and(VecDeque::is_empty)
- {
- self.cancelled_batches.remove(&channel_id);
- }
- let (cancelled_events, cancel_reason) = cancelled.map_or_else(
+ let (cancelled_events, cancel_reason) = self.pop_cancelled(channel_id).map_or_else(
|| (vec![], None),
|batch| (batch.events, Some(batch.reason)),
);
@@ -426,6 +365,37 @@ impl EventQueue {
})
}
+ /// Pop the oldest cancelled batch for `channel_id`, dropping the map entry
+ /// once its queue is empty. `None` if the channel has no cancelled batches.
+ fn pop_cancelled(&mut self, channel_id: Uuid) -> Option {
+ let batches = self.cancelled_batches.get_mut(&channel_id)?;
+ let cancelled = batches.pop_front();
+ if batches.is_empty() {
+ self.cancelled_batches.remove(&channel_id);
+ }
+ cancelled
+ }
+
+ /// Re-dispatch the oldest cancelled batch for `channel_id` unchanged under
+ /// its original conversation root, marking the channel in-flight.
+ fn dispatch_cancelled(&mut self, channel_id: Uuid, now: Instant) -> FlushBatch {
+ let cancelled = self
+ .pop_cancelled(channel_id)
+ .expect("cancelled channel must have a batch");
+ self.in_flight_channels.insert(channel_id);
+ self.in_flight_deadlines
+ .insert(channel_id, now + self.in_flight_deadline);
+ self.in_flight_batch_sizes
+ .insert(channel_id, cancelled.events.len());
+ FlushBatch {
+ channel_id,
+ conversation_root: cancelled.conversation_root,
+ events: cancelled.events,
+ cancelled_events: vec![],
+ cancel_reason: Some(cancelled.reason),
+ }
+ }
+
/// Mark the prompt for `channel_id` as complete.
///
/// Removes the channel from `in_flight_channels` and `in_flight_deadlines`.
From ae1bbc8d6430b80cde9bbcbcf1f71cd6c01d006c Mon Sep 17 00:00:00 2001
From: npub1zurdm6fx3kksz8f8d8y4js3mvjyu3rshlpqejr8wa8hta5tsqqeshmes7w
<1706dde9268dad011d2769c959423b6489c88e17f841990ceee9eebed1700033@sprout-oss.stage.blox.sqprod.co>
Date: Sat, 18 Jul 2026 12:39:21 -0700
Subject: [PATCH 03/11] fix(desktop): resolve CI fmt and file-size failures
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three fixes for PR CI:
- fmt: apply rustfmt to the rebased command_env helper in
managed_agents/runtime/tests.rs (Rust Lint failure).
- lib.rs over the 1000-line limit: move the window/haptic tauri
commands (perform_sidebar_default_haptic, title_bar_double_click,
fill_window, toggle_maximize) into commands/window_actions.rs where
they belong with the other frontend-forwarded commands
(1012 -> 901 lines, back under the default limit).
- app_state.rs over its ratchet: drop the AppState field for the
experiment entirely. The experiment flag now lives in
commands/experiments.rs as a process-local atomic, lazily hydrated
from the Rust-owned store on first read. The hydrate-before-restore
ordering invariant now holds by construction (the spawn boundary is
the first reader) instead of by an app-setup ordering requirement,
and app_state.rs is byte-identical to main β one less shared surface
the experiment touches.
desktop src-tauri: cargo test 1483 passed, clippy and fmt clean, and
node scripts/check-file-sizes.mjs passes.
Co-authored-by: npub1zurdm6fx3kksz8f8d8y4js3mvjyu3rshlpqejr8wa8hta5tsqqeshmes7w <1706dde9268dad011d2769c959423b6489c88e17f841990ceee9eebed1700033@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1zurdm6fx3kksz8f8d8y4js3mvjyu3rshlpqejr8wa8hta5tsqqeshmes7w <1706dde9268dad011d2769c959423b6489c88e17f841990ceee9eebed1700033@sprout-oss.stage.blox.sqprod.co>
---
desktop/src-tauri/src/app_state.rs | 3 -
desktop/src-tauri/src/commands/experiments.rs | 52 +++++----
desktop/src-tauri/src/commands/mod.rs | 2 +
.../src-tauri/src/commands/window_actions.rs | 102 ++++++++++++++++
desktop/src-tauri/src/lib.rs | 110 ------------------
.../src-tauri/src/managed_agents/runtime.rs | 8 +-
.../src/managed_agents/runtime/tests.rs | 7 +-
7 files changed, 137 insertions(+), 147 deletions(-)
create mode 100644 desktop/src-tauri/src/commands/window_actions.rs
diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs
index bab9c94315..3d910c3133 100644
--- a/desktop/src-tauri/src/app_state.rs
+++ b/desktop/src-tauri/src/app_state.rs
@@ -38,8 +38,6 @@ pub struct AppState {
/// records. Disabled by the agent-managed profiles experiment so an agent's
/// own profile updates are not overwritten on start or restore.
pub managed_agent_profile_reconcile_enabled: AtomicBool,
- /// Process-local in-app experiment forwarded to newly spawned ACP harnesses.
- pub acp_top_level_sessions_experiment: AtomicBool,
/// Shared shutdown signal checked by launch-time agent restoration.
pub shutdown_started: AtomicBool,
/// Serializes the restore spawn/register transition with shutdown cleanup,
@@ -203,7 +201,6 @@ pub fn build_app_state() -> AppState {
relay_url_override: Mutex::new(None),
managed_agent_restore_pending: AtomicBool::new(false),
managed_agent_profile_reconcile_enabled: AtomicBool::new(true),
- acp_top_level_sessions_experiment: AtomicBool::new(false),
shutdown_started: AtomicBool::new(false),
managed_agent_restore_transition: Mutex::new(()),
identity_mutation: Mutex::new(()),
diff --git a/desktop/src-tauri/src/commands/experiments.rs b/desktop/src-tauri/src/commands/experiments.rs
index 7014fd0f88..a696a2145c 100644
--- a/desktop/src-tauri/src/commands/experiments.rs
+++ b/desktop/src-tauri/src/commands/experiments.rs
@@ -1,17 +1,23 @@
use std::{
fs,
path::{Path, PathBuf},
- sync::atomic::Ordering,
+ sync::atomic::{AtomicBool, Ordering},
+ sync::Once,
};
use atomic_write_file::AtomicWriteFile;
use serde::{Deserialize, Serialize};
-use tauri::{AppHandle, Manager, State};
-
-use crate::app_state::AppState;
+use tauri::{AppHandle, Manager};
const EXPERIMENTS_FILE: &str = "desktop-experiments.json";
+/// Process-local experiment state, lazily hydrated from the Rust-owned store on
+/// first read so every reader (spawn boundary, frontend) sees persisted state
+/// without an app-setup ordering requirement. A corrupt store fails closed:
+/// the error is logged and the disabled default stays.
+static ACP_TOP_LEVEL_SESSIONS: AtomicBool = AtomicBool::new(false);
+static HYDRATE: Once = Once::new();
+
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default)]
struct DesktopExperiments {
@@ -52,36 +58,36 @@ fn save_experiments(path: &Path, experiments: &DesktopExperiments) -> Result<(),
.map_err(|error| format!("commit {}: {error}", path.display()))
}
-/// Hydrate process state from the Rust-owned store before managed-agent restore.
-pub(crate) fn hydrate_desktop_experiments(app: &AppHandle, state: &AppState) -> Result<(), String> {
- let experiments = load_experiments(&experiments_path(app)?)?;
- state
- .acp_top_level_sessions_experiment
- .store(experiments.acp_top_level_sessions, Ordering::Release);
- Ok(())
+/// Read the experiment, hydrating from the store on first access.
+pub(crate) fn acp_top_level_sessions_enabled(app: &AppHandle) -> bool {
+ HYDRATE.call_once(
+ || match experiments_path(app).and_then(|path| load_experiments(&path)) {
+ Ok(experiments) => {
+ ACP_TOP_LEVEL_SESSIONS.store(experiments.acp_top_level_sessions, Ordering::Release);
+ }
+ Err(error) => {
+ eprintln!("buzz-desktop: failed to hydrate desktop experiments: {error}");
+ }
+ },
+ );
+ ACP_TOP_LEVEL_SESSIONS.load(Ordering::Acquire)
}
#[tauri::command]
-pub fn get_acp_top_level_sessions_experiment(state: State<'_, AppState>) -> bool {
- state
- .acp_top_level_sessions_experiment
- .load(Ordering::Acquire)
+pub fn get_acp_top_level_sessions_experiment(app: AppHandle) -> bool {
+ acp_top_level_sessions_enabled(&app)
}
/// Durably apply the experiment before exposing it to subsequently spawned agents.
#[tauri::command]
-pub fn set_acp_top_level_sessions_experiment(
- enabled: bool,
- app: AppHandle,
- state: State<'_, AppState>,
-) -> Result<(), String> {
+pub fn set_acp_top_level_sessions_experiment(enabled: bool, app: AppHandle) -> Result<(), String> {
let path = experiments_path(&app)?;
let mut experiments = load_experiments(&path)?;
experiments.acp_top_level_sessions = enabled;
save_experiments(&path, &experiments)?;
- state
- .acp_top_level_sessions_experiment
- .store(enabled, Ordering::Release);
+ // Persisted first: even if first-read hydration races this store, it
+ // re-reads the same on-disk value.
+ ACP_TOP_LEVEL_SESSIONS.store(enabled, Ordering::Release);
Ok(())
}
diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs
index 9d0987f949..476c976fca 100644
--- a/desktop/src-tauri/src/commands/mod.rs
+++ b/desktop/src-tauri/src/commands/mod.rs
@@ -46,6 +46,7 @@ mod social;
mod team_snapshot;
mod teams;
mod updater;
+mod window_actions;
mod window_vibrancy;
mod workflows;
mod workspace;
@@ -93,6 +94,7 @@ pub use social::*;
pub use team_snapshot::*;
pub use teams::*;
pub use updater::*;
+pub use window_actions::*;
pub use window_vibrancy::*;
pub use workflows::*;
pub use workspace::*;
diff --git a/desktop/src-tauri/src/commands/window_actions.rs b/desktop/src-tauri/src/commands/window_actions.rs
new file mode 100644
index 0000000000..db70fe728d
--- /dev/null
+++ b/desktop/src-tauri/src/commands/window_actions.rs
@@ -0,0 +1,102 @@
+//! Native window and haptic commands forwarded from the web frontend.
+
+#[tauri::command]
+pub fn perform_sidebar_default_haptic() {
+ #[cfg(target_os = "macos")]
+ {
+ use objc2_app_kit::{
+ NSHapticFeedbackManager, NSHapticFeedbackPattern, NSHapticFeedbackPerformanceTime,
+ NSHapticFeedbackPerformer,
+ };
+
+ NSHapticFeedbackManager::defaultPerformer().performFeedbackPattern_performanceTime(
+ NSHapticFeedbackPattern::Alignment,
+ NSHapticFeedbackPerformanceTime::Now,
+ );
+ }
+}
+
+/// Performs the window action matching the macOS "double-click a window's
+/// title bar to" preference (`AppleActionOnDoubleClick`).
+///
+/// macOS values are `Minimize`, `Maximize` (default when unset), `Fill`, or
+/// `None`.
+/// The desktop app uses a web-based title-bar drag region, so the frontend
+/// forwards double-clicks here and suppresses Tauri's injected drag-region
+/// handler, whose default macOS path hardcodes maximize.
+///
+/// For `Fill`, resize to the current monitor work area instead of using
+/// Tauri's maximize path, which maps to macOS zoom for titled, resizable
+/// windows.
+///
+/// On non-macOS platforms this always toggles maximize (the historical
+/// behavior).
+#[tauri::command]
+pub fn title_bar_double_click(window: tauri::Window) {
+ #[cfg(target_os = "macos")]
+ {
+ let action = {
+ let output = std::process::Command::new("defaults")
+ .args(["read", "-g", "AppleActionOnDoubleClick"])
+ .output();
+ match output {
+ Ok(output) if output.status.success() => {
+ String::from_utf8_lossy(&output.stdout).trim().to_string()
+ }
+ _ => "Maximize".to_string(),
+ }
+ };
+
+ match action.as_str() {
+ "None" => {}
+ "Minimize" => {
+ let _ = window.minimize();
+ }
+ "Fill" => {
+ fill_window(&window);
+ }
+ // "Maximize" or any unexpected value.
+ _ => {
+ toggle_maximize(&window);
+ }
+ }
+ }
+
+ #[cfg(not(target_os = "macos"))]
+ {
+ toggle_maximize(&window);
+ }
+}
+
+/// Fills the current display work area, excluding system UI like the menu bar
+/// and Dock.
+#[cfg(target_os = "macos")]
+fn fill_window(window: &tauri::Window) {
+ match window.current_monitor() {
+ Ok(Some(monitor)) => {
+ if window.is_maximized().unwrap_or(false) {
+ let _ = window.unmaximize();
+ }
+
+ let work_area = monitor.work_area();
+ let _ = window.set_position(work_area.position);
+ let _ = window.set_size(work_area.size);
+ }
+ _ => {
+ let _ = window.maximize();
+ }
+ }
+}
+
+/// Toggles the window between maximized and its previous size, matching the
+/// historical double-click behavior.
+fn toggle_maximize(window: &tauri::Window) {
+ match window.is_maximized() {
+ Ok(true) => {
+ let _ = window.unmaximize();
+ }
+ _ => {
+ let _ = window.maximize();
+ }
+ }
+}
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index cf98222727..2aadaf54c2 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -63,106 +63,6 @@ use tauri_plugin_window_state::StateFlags;
#[cfg(target_os = "macos")]
const INITIAL_RENDER_READY_EVENT: &str = "initial-render-ready";
-#[tauri::command]
-fn perform_sidebar_default_haptic() {
- #[cfg(target_os = "macos")]
- {
- use objc2_app_kit::{
- NSHapticFeedbackManager, NSHapticFeedbackPattern, NSHapticFeedbackPerformanceTime,
- NSHapticFeedbackPerformer,
- };
-
- NSHapticFeedbackManager::defaultPerformer().performFeedbackPattern_performanceTime(
- NSHapticFeedbackPattern::Alignment,
- NSHapticFeedbackPerformanceTime::Now,
- );
- }
-}
-
-/// Performs the window action matching the macOS "double-click a window's
-/// title bar to" preference (`AppleActionOnDoubleClick`).
-///
-/// macOS values are `Minimize`, `Maximize` (default when unset), `Fill`, or
-/// `None`.
-/// The desktop app uses a web-based title-bar drag region, so the frontend
-/// forwards double-clicks here and suppresses Tauri's injected drag-region
-/// handler, whose default macOS path hardcodes maximize.
-///
-/// For `Fill`, resize to the current monitor work area instead of using
-/// Tauri's maximize path, which maps to macOS zoom for titled, resizable
-/// windows.
-///
-/// On non-macOS platforms this always toggles maximize (the historical
-/// behavior).
-#[tauri::command]
-fn title_bar_double_click(window: tauri::Window) {
- #[cfg(target_os = "macos")]
- {
- let action = {
- let output = std::process::Command::new("defaults")
- .args(["read", "-g", "AppleActionOnDoubleClick"])
- .output();
- match output {
- Ok(output) if output.status.success() => {
- String::from_utf8_lossy(&output.stdout).trim().to_string()
- }
- _ => "Maximize".to_string(),
- }
- };
-
- match action.as_str() {
- "None" => {}
- "Minimize" => {
- let _ = window.minimize();
- }
- "Fill" => {
- fill_window(&window);
- }
- // "Maximize" or any unexpected value.
- _ => {
- toggle_maximize(&window);
- }
- }
- }
-
- #[cfg(not(target_os = "macos"))]
- {
- toggle_maximize(&window);
- }
-}
-
-/// Fills the current display work area, excluding system UI like the menu bar
-/// and Dock.
-#[cfg(target_os = "macos")]
-fn fill_window(window: &tauri::Window) {
- match window.current_monitor() {
- Ok(Some(monitor)) => {
- if window.is_maximized().unwrap_or(false) {
- let _ = window.unmaximize();
- }
-
- let work_area = monitor.work_area();
- let _ = window.set_position(work_area.position);
- let _ = window.set_size(work_area.size);
- }
- _ => {
- let _ = window.maximize();
- }
- }
-}
-
-/// Toggles the window between maximized and its previous size, matching the
-/// historical double-click behavior.
-fn toggle_maximize(window: &tauri::Window) {
- match window.is_maximized() {
- Ok(true) => {
- let _ = window.unmaximize();
- }
- _ => {
- let _ = window.maximize();
- }
- }
-}
fn reveal_initial_window(window: &tauri::Window) {
if let Err(error) = window.show() {
@@ -490,16 +390,6 @@ pub fn run() {
migration::run_boot_migrations(&app_handle);
}
- // Hydrate Rust-owned experiments before any launch-time managed-agent
- // restore can spawn a process. A corrupt store fails closed and leaves
- // the disabled default in place.
- let state = app_handle.state::();
- if let Err(error) =
- commands::experiments::hydrate_desktop_experiments(&app_handle, &state)
- {
- eprintln!("buzz-desktop: failed to hydrate desktop experiments: {error}");
- }
-
// Resolve persisted identity key (env var β file β generate+save).
// This is fatal β the app should not start with an ephemeral identity
// that will be lost on restart, as that silently breaks channel
diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs
index 264d9241d0..ba951a6cde 100644
--- a/desktop/src-tauri/src/managed_agents/runtime.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime.rs
@@ -1714,13 +1714,7 @@ pub fn spawn_agent_child(
// Legacy default. User env may override this while the experiment is off;
// enabled experiment state is authoritatively finalized at the spawn boundary.
command.env("BUZZ_ACP_MULTIPLE_EVENT_HANDLING", "steer");
- let top_level_sessions = {
- use std::sync::atomic::Ordering;
- use tauri::Manager;
- app.state::()
- .acp_top_level_sessions_experiment
- .load(Ordering::Acquire)
- };
+ let top_level_sessions = crate::commands::experiments::acp_top_level_sessions_enabled(app);
command.env("BUZZ_ACP_DEDUP", "queue");
if let Some(meta) = runtime_meta {
for (key, value) in meta.default_env {
diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs
index 8d395acaab..9c5bdf1071 100644
--- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs
@@ -761,10 +761,9 @@ fn own_group_grandchild_detected_by_ancestor_walk() {
let _ = intermediate.wait();
}
-fn command_env(command: &std::process::Command) -> std::collections::HashMap<
- std::ffi::OsString,
- std::ffi::OsString,
-> {
+fn command_env(
+ command: &std::process::Command,
+) -> std::collections::HashMap {
command
.get_envs()
.filter_map(|(key, value)| value.map(|value| (key.to_owned(), value.to_owned())))
From 2444b17b378c8a66dc6ad65f71f265d1d4ee7dbe Mon Sep 17 00:00:00 2001
From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
<5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Date: Sat, 18 Jul 2026 16:55:25 -0400
Subject: [PATCH 04/11] feat(acp): make thread session scope the durable
default
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
---
crates/buzz-acp/src/config.rs | 45 +++++-
crates/buzz-acp/src/lib.rs | 6 +-
desktop/src-tauri/src/commands/experiments.rs | 143 ++++++++++++------
desktop/src-tauri/src/lib.rs | 4 +-
.../src-tauri/src/managed_agents/runtime.rs | 25 ++-
.../src/managed_agents/runtime/tests.rs | 31 ++--
.../ui/AcpSessionScopeSettingsCard.tsx | 78 ++++++++++
.../settings/ui/ExperimentalFeaturesCard.tsx | 75 +--------
.../features/settings/ui/SettingsPanels.tsx | 2 +
...st.mjs => acpSessionScopeSetting.test.mjs} | 30 ++--
...xperiment.ts => acpSessionScopeSetting.ts} | 28 ++--
preview-features.json | 8 -
12 files changed, 286 insertions(+), 189 deletions(-)
create mode 100644 desktop/src/features/settings/ui/AcpSessionScopeSettingsCard.tsx
rename desktop/src/features/settings/ui/{acpTopLevelSessionsExperiment.test.mjs => acpSessionScopeSetting.test.mjs} (80%)
rename desktop/src/features/settings/ui/{acpTopLevelSessionsExperiment.ts => acpSessionScopeSetting.ts} (63%)
diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs
index 2bac14ead5..8b45496e75 100644
--- a/crates/buzz-acp/src/config.rs
+++ b/crates/buzz-acp/src/config.rs
@@ -60,6 +60,15 @@ pub enum DedupMode {
Queue,
}
+/// Scope used for scheduling and retained ACP session identity.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
+pub enum SessionScope {
+ /// One independent execution lane and session per outermost conversation root.
+ Thread,
+ /// Legacy behavior: one serialized execution lane and session per channel.
+ Channel,
+}
+
/// How to handle new @mentions while a turn is already in-flight for that channel.
#[derive(Debug, Clone, Copy, PartialEq, clap::ValueEnum)]
pub enum MultipleEventHandling {
@@ -468,10 +477,15 @@ pub struct CliArgs {
#[arg(long, env = "BUZZ_ACP_RELAY_OBSERVER", default_value_t = false)]
pub relay_observer: bool,
- /// Isolate managed-agent ACP sessions by human conversation root.
- /// Disabled by default; Desktop enables it through its in-app experiment.
- #[arg(long, env = "BUZZ_ACP_TOP_LEVEL_SESSIONS", default_value_t = false)]
- pub top_level_sessions: bool,
+ /// Scheduling and retained-session scope. Thread scope is the durable default;
+ /// channel scope preserves the legacy one-session-per-channel behavior.
+ #[arg(
+ long,
+ env = "BUZZ_ACP_SESSION_SCOPE",
+ default_value = "thread",
+ value_enum
+ )]
+ pub session_scope: SessionScope,
}
/// Merged NIP-01 subscription filter for a single channel.
@@ -542,8 +556,8 @@ pub struct Config {
pub has_generated_codex_config: bool,
/// Whether to publish encrypted observer frames through the relay.
pub relay_observer: bool,
- /// Whether channel turns use conversation-root-scoped ACP sessions.
- pub top_level_sessions: bool,
+ /// Scheduling and retained-session scope.
+ pub session_scope: SessionScope,
/// Agent owner pubkey (hex). Used for `--respond-to=owner-only` gate.
/// Replaces the old REST-based owner lookup.
pub agent_owner: Option,
@@ -1003,7 +1017,7 @@ impl Config {
persona_env_vars,
has_generated_codex_config,
relay_observer: args.relay_observer,
- top_level_sessions: args.top_level_sessions,
+ session_scope: args.session_scope,
agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()),
no_base_prompt: args.no_base_prompt,
base_prompt_content,
@@ -1377,7 +1391,7 @@ mod tests {
has_generated_codex_config: false,
relay_observer: false,
agent_owner: None,
- top_level_sessions: false,
+ session_scope: SessionScope::Channel,
no_base_prompt: false,
base_prompt_content: None,
}
@@ -2389,6 +2403,21 @@ channels = "ALL"
// ββ Multiple-event-handling validation + default ββββββββββββββββββββββββββ
+ #[test]
+ fn test_session_scope_defaults_to_thread_and_accepts_legacy_channel_mode() {
+ let default_args = CliArgs::parse_from(["buzz-acp", "--private-key", &"0".repeat(64)]);
+ assert_eq!(default_args.session_scope, SessionScope::Thread);
+
+ let channel_args = CliArgs::parse_from([
+ "buzz-acp",
+ "--private-key",
+ &"0".repeat(64),
+ "--session-scope",
+ "channel",
+ ]);
+ assert_eq!(channel_args.session_scope, SessionScope::Channel);
+ }
+
#[test]
fn test_multiple_event_handling_default_is_steer() {
// Parse a minimal arg set; the default for --multiple-event-handling
diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs
index f291c0edd6..44309138d0 100644
--- a/crates/buzz-acp/src/lib.rs
+++ b/crates/buzz-acp/src/lib.rs
@@ -2031,7 +2031,7 @@ async fn tokio_main() -> Result<()> {
event: buzz_event.event,
received_at: std::time::Instant::now(),
prompt_tag,
- conversation_root: if config.top_level_sessions {
+ conversation_root: if matches!(config.session_scope, config::SessionScope::Thread) {
let tags = queue::parse_thread_tags(&event_for_steer);
Some(tags.root_event_id.unwrap_or_else(|| event_id_hex.clone()))
} else {
@@ -4181,7 +4181,7 @@ mod build_mcp_servers_tests {
has_generated_codex_config: false,
relay_observer: false,
agent_owner: None,
- top_level_sessions: false,
+ session_scope: config::SessionScope::Channel,
no_base_prompt: false,
base_prompt_content: None,
}
@@ -4347,7 +4347,7 @@ mod error_outcome_emission_tests {
has_generated_codex_config: false,
relay_observer: false,
agent_owner: None,
- top_level_sessions: false,
+ session_scope: config::SessionScope::Channel,
no_base_prompt: false,
base_prompt_content: None,
}
diff --git a/desktop/src-tauri/src/commands/experiments.rs b/desktop/src-tauri/src/commands/experiments.rs
index a696a2145c..e7c971d3fa 100644
--- a/desktop/src-tauri/src/commands/experiments.rs
+++ b/desktop/src-tauri/src/commands/experiments.rs
@@ -1,7 +1,7 @@
use std::{
fs,
path::{Path, PathBuf},
- sync::atomic::{AtomicBool, Ordering},
+ sync::atomic::{AtomicU8, Ordering},
sync::Once,
};
@@ -9,32 +9,69 @@ use atomic_write_file::AtomicWriteFile;
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager};
-const EXPERIMENTS_FILE: &str = "desktop-experiments.json";
+const SETTINGS_FILE: &str = "desktop-experiments.json";
+const THREAD_SCOPE: u8 = 0;
+const CHANNEL_SCOPE: u8 = 1;
-/// Process-local experiment state, lazily hydrated from the Rust-owned store on
-/// first read so every reader (spawn boundary, frontend) sees persisted state
-/// without an app-setup ordering requirement. A corrupt store fails closed:
-/// the error is logged and the disabled default stays.
-static ACP_TOP_LEVEL_SESSIONS: AtomicBool = AtomicBool::new(false);
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum AcpSessionScope {
+ Thread,
+ Channel,
+}
+
+impl AcpSessionScope {
+ fn atomic_value(self) -> u8 {
+ match self {
+ Self::Thread => THREAD_SCOPE,
+ Self::Channel => CHANNEL_SCOPE,
+ }
+ }
+}
+
+/// Process-local setting, lazily hydrated from the Rust-owned store on first
+/// read. Thread scope is the durable default when no explicit choice exists.
+static ACP_SESSION_SCOPE: AtomicU8 = AtomicU8::new(THREAD_SCOPE);
static HYDRATE: Once = Once::new();
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default)]
-struct DesktopExperiments {
- acp_top_level_sessions: bool,
+struct DesktopSettings {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ acp_session_scope: Option,
+ // Migration from the preview experiment. Remove after existing installs
+ // have had a release cycle to persist `acp_session_scope`.
+ #[serde(skip_serializing)]
+ acp_top_level_sessions: Option,
+}
+
+impl DesktopSettings {
+ fn session_scope(&self) -> AcpSessionScope {
+ self.acp_session_scope.unwrap_or_else(|| {
+ self.acp_top_level_sessions
+ .map(|enabled| {
+ if enabled {
+ AcpSessionScope::Thread
+ } else {
+ AcpSessionScope::Channel
+ }
+ })
+ .unwrap_or(AcpSessionScope::Thread)
+ })
+ }
}
-fn experiments_path(app: &AppHandle) -> Result {
+fn settings_path(app: &AppHandle) -> Result {
Ok(app
.path()
.app_data_dir()
.map_err(|error| format!("app data dir: {error}"))?
- .join(EXPERIMENTS_FILE))
+ .join(SETTINGS_FILE))
}
-fn load_experiments(path: &Path) -> Result {
+fn load_settings(path: &Path) -> Result {
if !path.exists() {
- return Ok(DesktopExperiments::default());
+ return Ok(DesktopSettings::default());
}
let payload =
fs::read(path).map_err(|error| format!("failed to read {}: {error}", path.display()))?;
@@ -42,13 +79,13 @@ fn load_experiments(path: &Path) -> Result {
.map_err(|error| format!("failed to parse {}: {error}", path.display()))
}
-fn save_experiments(path: &Path, experiments: &DesktopExperiments) -> Result<(), String> {
+fn save_settings(path: &Path, settings: &DesktopSettings) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
}
- let payload = serde_json::to_vec_pretty(experiments)
- .map_err(|error| format!("failed to serialize experiments: {error}"))?;
+ let payload = serde_json::to_vec_pretty(settings)
+ .map_err(|error| format!("failed to serialize settings: {error}"))?;
let mut file = AtomicWriteFile::open(path)
.map_err(|error| format!("open {} for atomic write: {error}", path.display()))?;
use std::io::Write;
@@ -58,70 +95,84 @@ fn save_experiments(path: &Path, experiments: &DesktopExperiments) -> Result<(),
.map_err(|error| format!("commit {}: {error}", path.display()))
}
-/// Read the experiment, hydrating from the store on first access.
-pub(crate) fn acp_top_level_sessions_enabled(app: &AppHandle) -> bool {
+pub(crate) fn acp_session_scope(app: &AppHandle) -> AcpSessionScope {
HYDRATE.call_once(
- || match experiments_path(app).and_then(|path| load_experiments(&path)) {
- Ok(experiments) => {
- ACP_TOP_LEVEL_SESSIONS.store(experiments.acp_top_level_sessions, Ordering::Release);
+ || match settings_path(app).and_then(|path| load_settings(&path)) {
+ Ok(settings) => {
+ ACP_SESSION_SCOPE.store(settings.session_scope().atomic_value(), Ordering::Release)
}
Err(error) => {
- eprintln!("buzz-desktop: failed to hydrate desktop experiments: {error}");
+ eprintln!("buzz-desktop: failed to hydrate desktop settings: {error}");
}
},
);
- ACP_TOP_LEVEL_SESSIONS.load(Ordering::Acquire)
+ match ACP_SESSION_SCOPE.load(Ordering::Acquire) {
+ CHANNEL_SCOPE => AcpSessionScope::Channel,
+ _ => AcpSessionScope::Thread,
+ }
}
#[tauri::command]
-pub fn get_acp_top_level_sessions_experiment(app: AppHandle) -> bool {
- acp_top_level_sessions_enabled(&app)
+pub fn get_acp_session_scope(app: AppHandle) -> AcpSessionScope {
+ acp_session_scope(&app)
}
-/// Durably apply the experiment before exposing it to subsequently spawned agents.
#[tauri::command]
-pub fn set_acp_top_level_sessions_experiment(enabled: bool, app: AppHandle) -> Result<(), String> {
- let path = experiments_path(&app)?;
- let mut experiments = load_experiments(&path)?;
- experiments.acp_top_level_sessions = enabled;
- save_experiments(&path, &experiments)?;
- // Persisted first: even if first-read hydration races this store, it
- // re-reads the same on-disk value.
- ACP_TOP_LEVEL_SESSIONS.store(enabled, Ordering::Release);
+pub fn set_acp_session_scope(scope: AcpSessionScope, app: AppHandle) -> Result<(), String> {
+ let path = settings_path(&app)?;
+ let mut settings = load_settings(&path)?;
+ settings.acp_session_scope = Some(scope);
+ settings.acp_top_level_sessions = None;
+ save_settings(&path, &settings)?;
+ ACP_SESSION_SCOPE.store(scope.atomic_value(), Ordering::Release);
Ok(())
}
#[cfg(test)]
mod tests {
- use super::{load_experiments, save_experiments, DesktopExperiments};
+ use super::{load_settings, save_settings, AcpSessionScope, DesktopSettings};
#[test]
- fn missing_store_defaults_disabled() {
+ fn missing_store_defaults_to_thread_scope() {
let dir = tempfile::tempdir().unwrap();
- let loaded = load_experiments(&dir.path().join("missing.json")).unwrap();
- assert!(!loaded.acp_top_level_sessions);
+ let loaded = load_settings(&dir.path().join("missing.json")).unwrap();
+ assert_eq!(loaded.session_scope(), AcpSessionScope::Thread);
}
#[test]
- fn persisted_enabled_state_round_trips_for_fresh_launch() {
+ fn explicit_channel_scope_round_trips() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("desktop-experiments.json");
- save_experiments(
+ save_settings(
&path,
- &DesktopExperiments {
- acp_top_level_sessions: true,
+ &DesktopSettings {
+ acp_session_scope: Some(AcpSessionScope::Channel),
+ acp_top_level_sessions: None,
},
)
.unwrap();
- let loaded = load_experiments(&path).unwrap();
- assert!(loaded.acp_top_level_sessions);
+ assert_eq!(
+ load_settings(&path).unwrap().session_scope(),
+ AcpSessionScope::Channel
+ );
+ }
+
+ #[test]
+ fn migrates_explicit_preview_opt_out_to_channel_scope() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("desktop-experiments.json");
+ std::fs::write(&path, br#"{"acp_top_level_sessions":false}"#).unwrap();
+ assert_eq!(
+ load_settings(&path).unwrap().session_scope(),
+ AcpSessionScope::Channel
+ );
}
#[test]
- fn malformed_store_fails_closed_instead_of_enabling() {
+ fn malformed_store_returns_error_and_keeps_process_default() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("desktop-experiments.json");
std::fs::write(&path, b"not json").unwrap();
- assert!(load_experiments(&path).is_err());
+ assert!(load_settings(&path).is_err());
}
}
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 2aadaf54c2..139bef68e6 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -746,8 +746,8 @@ pub fn run() {
list_managed_agents,
create_managed_agent,
start_managed_agent,
- get_acp_top_level_sessions_experiment,
- set_acp_top_level_sessions_experiment,
+ get_acp_session_scope,
+ set_acp_session_scope,
stop_managed_agent,
set_agent_managed_profiles,
set_managed_agent_start_on_app_launch,
diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs
index ba951a6cde..611364b0dc 100644
--- a/desktop/src-tauri/src/managed_agents/runtime.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime.rs
@@ -1714,7 +1714,7 @@ pub fn spawn_agent_child(
// Legacy default. User env may override this while the experiment is off;
// enabled experiment state is authoritatively finalized at the spawn boundary.
command.env("BUZZ_ACP_MULTIPLE_EVENT_HANDLING", "steer");
- let top_level_sessions = crate::commands::experiments::acp_top_level_sessions_enabled(app);
+ let session_scope = crate::commands::experiments::acp_session_scope(app);
command.env("BUZZ_ACP_DEDUP", "queue");
if let Some(meta) = runtime_meta {
for (key, value) in meta.default_env {
@@ -1871,7 +1871,7 @@ pub fn spawn_agent_child(
// Finalize after every default and user env write. Enabled experiment state
// must be authoritative; disabled state only removes the new variable and
// preserves the legacy handling override chosen above or supplied by users.
- finalize_acp_top_level_sessions_env(&mut command, top_level_sessions);
+ finalize_acp_session_scope_env(&mut command, session_scope);
// Spawn the harness in its own process group so we can kill the entire
// tree (harness + MCP servers + agent subprocesses) on shutdown.
@@ -2128,17 +2128,16 @@ pub(crate) fn resolve_effective_prompt_model_provider(
}
}
-fn finalize_acp_top_level_sessions_env(command: &mut std::process::Command, enabled: bool) {
- if enabled {
- command.env("BUZZ_ACP_TOP_LEVEL_SESSIONS", "true");
- // Conversation roots remain channel-serialized but must never steer
- // into another root's in-flight ACP session.
- command.env("BUZZ_ACP_MULTIPLE_EVENT_HANDLING", "queue");
- } else {
- // The experiment's variable is never user-controlled while disabled.
- // Do not touch MULTIPLE_EVENT_HANDLING: legacy user overrides remain valid.
- command.env_remove("BUZZ_ACP_TOP_LEVEL_SESSIONS");
- }
+fn finalize_acp_session_scope_env(
+ command: &mut std::process::Command,
+ scope: crate::commands::experiments::AcpSessionScope,
+) {
+ let value = match scope {
+ crate::commands::experiments::AcpSessionScope::Thread => "thread",
+ crate::commands::experiments::AcpSessionScope::Channel => "channel",
+ };
+ command.env("BUZZ_ACP_SESSION_SCOPE", value);
+ command.env_remove("BUZZ_ACP_TOP_LEVEL_SESSIONS");
}
#[cfg(test)]
diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs
index 9c5bdf1071..817abb1e0e 100644
--- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs
@@ -771,35 +771,42 @@ fn command_env(
}
#[test]
-fn top_level_sessions_enabled_finalization_overrides_conflicting_later_env() {
+fn thread_scope_finalization_overrides_legacy_env_without_forcing_queue() {
let mut command = std::process::Command::new("buzz-acp");
- // Simulate conflicting runtime defaults/user env written after Buzz's legacy defaults.
command.env("BUZZ_ACP_TOP_LEVEL_SESSIONS", "false");
command.env("BUZZ_ACP_MULTIPLE_EVENT_HANDLING", "steer");
- super::finalize_acp_top_level_sessions_env(&mut command, true);
+ super::finalize_acp_session_scope_env(
+ &mut command,
+ crate::commands::experiments::AcpSessionScope::Thread,
+ );
let env = command_env(&command);
assert_eq!(
- env.get(std::ffi::OsStr::new("BUZZ_ACP_TOP_LEVEL_SESSIONS"))
+ env.get(std::ffi::OsStr::new("BUZZ_ACP_SESSION_SCOPE"))
.unwrap(),
- "true"
+ "thread"
);
+ assert!(!env.contains_key(std::ffi::OsStr::new("BUZZ_ACP_TOP_LEVEL_SESSIONS")));
assert_eq!(
env.get(std::ffi::OsStr::new("BUZZ_ACP_MULTIPLE_EVENT_HANDLING"))
.unwrap(),
- "queue"
+ "steer"
);
}
#[test]
-fn top_level_sessions_disabled_removes_flag_but_preserves_handling_override() {
+fn channel_scope_finalization_preserves_handling_override() {
let mut command = std::process::Command::new("buzz-acp");
- // Simulate conflicting later user env: the new flag is suppressed while the
- // pre-existing handling override remains compatible when experiment is off.
- command.env("BUZZ_ACP_TOP_LEVEL_SESSIONS", "true");
command.env("BUZZ_ACP_MULTIPLE_EVENT_HANDLING", "interrupt");
- super::finalize_acp_top_level_sessions_env(&mut command, false);
+ super::finalize_acp_session_scope_env(
+ &mut command,
+ crate::commands::experiments::AcpSessionScope::Channel,
+ );
let env = command_env(&command);
- assert!(!env.contains_key(std::ffi::OsStr::new("BUZZ_ACP_TOP_LEVEL_SESSIONS")));
+ assert_eq!(
+ env.get(std::ffi::OsStr::new("BUZZ_ACP_SESSION_SCOPE"))
+ .unwrap(),
+ "channel"
+ );
assert_eq!(
env.get(std::ffi::OsStr::new("BUZZ_ACP_MULTIPLE_EVENT_HANDLING"))
.unwrap(),
diff --git a/desktop/src/features/settings/ui/AcpSessionScopeSettingsCard.tsx b/desktop/src/features/settings/ui/AcpSessionScopeSettingsCard.tsx
new file mode 100644
index 0000000000..3579533a26
--- /dev/null
+++ b/desktop/src/features/settings/ui/AcpSessionScopeSettingsCard.tsx
@@ -0,0 +1,78 @@
+import { useEffect, useState } from "react";
+import { invokeTauri, listManagedAgents } from "@/shared/api/tauri";
+import {
+ startManagedAgent,
+ stopManagedAgent,
+} from "@/shared/api/tauriManagedAgents";
+import { Switch } from "@/shared/ui/switch";
+import { SettingsSectionHeader } from "./SettingsSectionHeader";
+import { applyAcpSessionScopeSetting } from "./acpSessionScopeSetting";
+
+type SessionScope = "thread" | "channel";
+
+export function AcpSessionScopeSettingsCard() {
+ const [scope, setScope] = useState("thread");
+ const [pending, setPending] = useState(true);
+
+ useEffect(() => {
+ let cancelled = false;
+ void invokeTauri("get_acp_session_scope")
+ .then((persisted) => {
+ if (!cancelled) setScope(persisted);
+ })
+ .catch((error) =>
+ console.error("Failed to hydrate ACP session scope", error),
+ )
+ .finally(() => {
+ if (!cancelled) setPending(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const setThreadScoped = async (threadScoped: boolean) => {
+ setPending(true);
+ try {
+ await applyAcpSessionScopeSetting(scope === "thread", threadScoped, {
+ setBackend: (next) =>
+ invokeTauri("set_acp_session_scope", { scope: next }),
+ listAgents: listManagedAgents,
+ stopAgent: stopManagedAgent,
+ startAgent: startManagedAgent,
+ setUi: (enabled) => setScope(enabled ? "thread" : "channel"),
+ });
+ } catch (error) {
+ console.error("Failed to apply ACP session scope", error);
+ } finally {
+ setPending(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ Thread-scoped sessions
+
+
+ Run separate threads concurrently. Turn this off for one legacy
+ session per channel.
+
+
+
void setThreadScoped(value)}
+ />
+
+
+ );
+}
diff --git a/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx b/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx
index 16e2e87efd..9e0064ad85 100644
--- a/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx
+++ b/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx
@@ -1,80 +1,20 @@
import { setAgentManagedProfiles } from "@/shared/api/tauri";
import { desktopFeatures, useFeatureToggle } from "@/shared/features";
-import { useEffect, useState } from "react";
+
import type { FeatureDefinition } from "@/shared/features";
-import { listManagedAgents } from "@/shared/api/tauri";
-import {
- startManagedAgent,
- stopManagedAgent,
-} from "@/shared/api/tauriManagedAgents";
-import { invokeTauri } from "@/shared/api/tauri";
import { Switch } from "@/shared/ui/switch";
import { SettingsSectionHeader } from "./SettingsSectionHeader";
-import { applyAcpTopLevelSessionsExperiment } from "./acpTopLevelSessionsExperiment";
function FeatureRow({ feature }: { feature: FeatureDefinition }) {
const [enabled, toggle] = useFeatureToggle(feature.id);
- const [pending, setPending] = useState(false);
const switchId = `feature-toggle-${feature.id}`;
- // Rust persistence is authoritative for this runtime experiment. Hydrate the
- // local feature store when the row mounts rather than pushing localStorage
- // into Tauri after launch-time agent restore has already run. Keep the switch
- // disabled until this one-shot hydration finishes so a stale local value
- // cannot race an in-flight toggle.
- const isAcpTopLevelSessions = feature.id === "acpTopLevelSessions";
- const [hydrated, setHydrated] = useState(!isAcpTopLevelSessions);
- useEffect(() => {
- if (!isAcpTopLevelSessions) return;
- let cancelled = false;
- void invokeTauri("get_acp_top_level_sessions_experiment")
- .then((persisted) => {
- if (cancelled) return;
- toggle(persisted);
- setHydrated(true);
- })
- .catch((error) => {
- if (!cancelled) {
- console.error(
- "Failed to hydrate ACP top-level sessions experiment",
- error,
- );
- }
- });
- return () => {
- cancelled = true;
- };
- }, [isAcpTopLevelSessions, toggle]);
-
- const handleToggle = async (value: boolean) => {
- if (feature.id !== "acpTopLevelSessions") {
- toggle(value);
- if (feature.id === "agentManagedProfiles") {
- void setAgentManagedProfiles(value).catch((error) => {
- console.error(
- "Failed to apply agent-managed profiles setting:",
- error,
- );
- });
- }
- return;
- }
- setPending(true);
- try {
- await applyAcpTopLevelSessionsExperiment(enabled, value, {
- setBackend: (next) =>
- invokeTauri("set_acp_top_level_sessions_experiment", {
- enabled: next,
- }),
- listAgents: listManagedAgents,
- stopAgent: stopManagedAgent,
- startAgent: startManagedAgent,
- setUi: toggle,
+ const handleToggle = (value: boolean) => {
+ toggle(value);
+ if (feature.id === "agentManagedProfiles") {
+ void setAgentManagedProfiles(value).catch((error) => {
+ console.error("Failed to apply agent-managed profiles setting:", error);
});
- } catch (error) {
- console.error("Failed to apply ACP top-level sessions experiment", error);
- } finally {
- setPending(false);
}
};
@@ -90,8 +30,7 @@ function FeatureRow({ feature }: { feature: FeatureDefinition }) {
aria-labelledby={`${switchId}-label`}
checked={enabled}
data-testid={switchId}
- disabled={pending || !hydrated}
- onCheckedChange={(value) => void handleToggle(value)}
+ onCheckedChange={handleToggle}
/>
);
diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx
index 767d758946..40a81b2f5a 100644
--- a/desktop/src/features/settings/ui/SettingsPanels.tsx
+++ b/desktop/src/features/settings/ui/SettingsPanels.tsx
@@ -57,6 +57,7 @@ import {
import { ChannelTemplatesSettingsCard } from "./ChannelTemplatesSettingsCard";
import { DoctorSettingsPanel } from "./DoctorSettingsPanel";
import { ExperimentalFeaturesCard } from "./ExperimentalFeaturesCard";
+import { AcpSessionScopeSettingsCard } from "./AcpSessionScopeSettingsCard";
import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard";
import { MeshComputeSettingsCard } from "@/features/mesh-compute/ui/MeshComputeSettingsCard";
import { MobilePairingCard } from "./MobilePairingCard";
@@ -717,6 +718,7 @@ export function renderSettingsSection(
);
case "channel-templates":
diff --git a/desktop/src/features/settings/ui/acpTopLevelSessionsExperiment.test.mjs b/desktop/src/features/settings/ui/acpSessionScopeSetting.test.mjs
similarity index 80%
rename from desktop/src/features/settings/ui/acpTopLevelSessionsExperiment.test.mjs
rename to desktop/src/features/settings/ui/acpSessionScopeSetting.test.mjs
index 1246206cc0..f020f64bb0 100644
--- a/desktop/src/features/settings/ui/acpTopLevelSessionsExperiment.test.mjs
+++ b/desktop/src/features/settings/ui/acpSessionScopeSetting.test.mjs
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
-import { applyAcpTopLevelSessionsExperiment } from "./acpTopLevelSessionsExperiment.ts";
+import { applyAcpSessionScopeSetting } from "./acpSessionScopeSetting.ts";
const localRunning = {
pubkey: "local",
@@ -23,7 +23,7 @@ function harness(overrides = {}) {
return {
calls,
deps: {
- setBackend: async (enabled) => calls.push(["backend", enabled]),
+ setBackend: async (scope) => calls.push(["backend", scope]),
listAgents: async () => [localRunning, remoteRunning, localStopped],
stopAgent: async (pubkey) => calls.push(["stop", pubkey]),
startAgent: async (pubkey) => calls.push(["start", pubkey]),
@@ -33,12 +33,12 @@ function harness(overrides = {}) {
};
}
-describe("ACP top-level sessions experiment", () => {
+describe("ACP session scope setting", () => {
it("commits UI only after applying backend state and restarting running local agents", async () => {
const { calls, deps } = harness();
- await applyAcpTopLevelSessionsExperiment(false, true, deps);
+ await applyAcpSessionScopeSetting(false, true, deps);
assert.deepEqual(calls, [
- ["backend", true],
+ ["backend", "thread"],
["stop", "local"],
["start", "local"],
["ui", true],
@@ -55,14 +55,14 @@ describe("ACP top-level sessions experiment", () => {
},
});
await assert.rejects(
- applyAcpTopLevelSessionsExperiment(false, true, deps),
+ applyAcpSessionScopeSetting(false, true, deps),
/restart failed/,
);
assert.deepEqual(calls, [
- ["backend", true],
+ ["backend", "thread"],
["stop", "local"],
["start", "local"],
- ["backend", false],
+ ["backend", "channel"],
["stop", "local"],
["start", "local"],
["ui", false],
@@ -71,13 +71,13 @@ describe("ACP top-level sessions experiment", () => {
it("rolls UI back when persistence fails before any restart", async () => {
const { calls, deps } = harness({
- setBackend: async (enabled) => {
- calls.push(["backend", enabled]);
- if (enabled) throw new Error("persist failed");
+ setBackend: async (scope) => {
+ calls.push(["backend", scope]);
+ if (scope === "thread") throw new Error("persist failed");
},
});
await assert.rejects(
- applyAcpTopLevelSessionsExperiment(false, true, deps),
+ applyAcpSessionScopeSetting(false, true, deps),
/persist failed/,
);
assert.equal(calls.at(-1)[0], "ui");
@@ -112,16 +112,16 @@ describe("ACP top-level sessions experiment", () => {
});
await assert.rejects(
- applyAcpTopLevelSessionsExperiment(false, true, deps),
+ applyAcpSessionScopeSetting(false, true, deps),
/apply failed/,
);
assert.deepEqual(calls, [
- ["backend", true],
+ ["backend", "thread"],
["stop", "first"],
["start", "first"],
["stop", "second"],
["start", "second"],
- ["backend", false],
+ ["backend", "channel"],
["stop", "first"],
["start", "first"],
["stop", "second"],
diff --git a/desktop/src/features/settings/ui/acpTopLevelSessionsExperiment.ts b/desktop/src/features/settings/ui/acpSessionScopeSetting.ts
similarity index 63%
rename from desktop/src/features/settings/ui/acpTopLevelSessionsExperiment.ts
rename to desktop/src/features/settings/ui/acpSessionScopeSetting.ts
index b7652c31ad..57a0044027 100644
--- a/desktop/src/features/settings/ui/acpTopLevelSessionsExperiment.ts
+++ b/desktop/src/features/settings/ui/acpSessionScopeSetting.ts
@@ -1,20 +1,20 @@
-export type ExperimentAgent = {
+export type SessionScopeAgent = {
pubkey: string;
status: string;
backend: { type: string };
};
-export type ExperimentToggleDependencies = {
- setBackend: (enabled: boolean) => Promise;
- listAgents: () => Promise;
+export type SessionScopeDependencies = {
+ setBackend: (scope: "thread" | "channel") => Promise;
+ listAgents: () => Promise;
stopAgent: (pubkey: string) => Promise;
startAgent: (pubkey: string) => Promise;
- setUi: (enabled: boolean) => void;
+ setUi: (threadScoped: boolean) => void;
};
async function restartRunningLocalAgents(
- agents: ExperimentAgent[],
- deps: ExperimentToggleDependencies,
+ agents: SessionScopeAgent[],
+ deps: SessionScopeDependencies,
): Promise {
for (const agent of agents) {
if (agent.status !== "running" || agent.backend.type !== "local") continue;
@@ -24,26 +24,26 @@ async function restartRunningLocalAgents(
}
/**
- * Apply the Rust-owned experiment and restart affected processes. The UI is
+ * Apply the Rust-owned session-scope setting and restart affected processes. The UI is
* committed only after every restart succeeds. On failure, both persisted
* backend state and already-restarted agents are restored best-effort.
*/
-export async function applyAcpTopLevelSessionsExperiment(
+export async function applyAcpSessionScopeSetting(
previous: boolean,
next: boolean,
- deps: ExperimentToggleDependencies,
+ deps: SessionScopeDependencies,
): Promise {
const agents = await deps.listAgents();
try {
- await deps.setBackend(next);
+ await deps.setBackend(next ? "thread" : "channel");
await restartRunningLocalAgents(agents, deps);
deps.setUi(next);
} catch (error) {
try {
- await deps.setBackend(previous);
+ await deps.setBackend(previous ? "thread" : "channel");
} catch (rollbackError) {
console.error(
- "Failed to roll back ACP top-level sessions backend state",
+ "Failed to roll back ACP session-scope backend state",
rollbackError,
);
}
@@ -55,7 +55,7 @@ export async function applyAcpTopLevelSessionsExperiment(
await deps.startAgent(agent.pubkey);
} catch (rollbackError) {
console.error(
- `Failed to roll back ACP experiment process ${agent.pubkey}`,
+ `Failed to roll back ACP session-scope process ${agent.pubkey}`,
rollbackError,
);
}
diff --git a/preview-features.json b/preview-features.json
index 43d0035ef5..ad8090d5ad 100644
--- a/preview-features.json
+++ b/preview-features.json
@@ -40,14 +40,6 @@
"platforms": [
"desktop"
]
- },
- {
- "id": "acpTopLevelSessions",
- "name": "Fresh agent sessions by conversation",
- "description": "Start each human top-level agent mention in an isolated ACP session",
- "platforms": [
- "desktop"
- ]
}
]
}
From eca05aa25758aed323663bc70f43a6a5a7e57b24 Mon Sep 17 00:00:00 2001
From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
<011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Date: Sat, 18 Jul 2026 17:49:24 -0400
Subject: [PATCH 05/11] =?UTF-8?q?feat(acp):=20thread-scoped=20concurrent?=
=?UTF-8?q?=20sessions=20=E2=80=94=20scope-keyed=20dispatch,=20affinity,?=
=?UTF-8?q?=20control=20resolution?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Scheduling identity becomes ConversationSessionKey (channel_id, root_event_id):
- queue.rs: all in-flight control state (deadlines, batch sizes, retry,
cancel sets, withheld native steer) keyed by scope; flush_next selects
the oldest non-in-flight scope so distinct roots flush concurrently;
restore_unclaimed is the exact lossless undo of flush_next.
- pool.rs: TaskMeta carries the scope; try_claim returns
ClaimOutcome{Claimed,BusyOwner,Exhausted} with strict retained-root
slot affinity and lazy owner pruning; send_steer scope-keyed.
- lib.rs: dispatch_pending dispatches multiple scopes per pass, holding
BusyOwner/Exhausted batches until after the loop (prevents
flushβrestoreβreflush livelock) then restoring losslessly; control
resolution never fans out across roots β explicit root targets
exactly, channel-level control resolves only when exactly one scope
is in flight (Err(n) = ambiguous, touch nothing); steer fork is
scope-exact. Channel mode remains the degenerate root=None path.
Deterministic coverage: cross-root concurrent dispatch, busy-owner skip
without root migration, pool-exhaustion lossless restore, three
restore_unclaimed exactness tests, control-scope resolution (channel
exactly-one rule, explicit control-frame root, threaded-event root
targeting, channel-mode fallback). cargo test -p buzz-acp: 557 passed.
Co-authored-by: Tyler Longwell
Signed-off-by: Tyler Longwell
---
crates/buzz-acp/src/lib.rs | 829 +++++++++++++++++++++++------
crates/buzz-acp/src/pool.rs | 111 ++--
crates/buzz-acp/src/queue.rs | 994 ++++++++++++++++++++---------------
3 files changed, 1310 insertions(+), 624 deletions(-)
diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs
index 44309138d0..8254eb8af1 100644
--- a/crates/buzz-acp/src/lib.rs
+++ b/crates/buzz-acp/src/lib.rs
@@ -36,8 +36,8 @@ use filter::SubscriptionRule;
use futures_util::FutureExt;
use nostr::{PublicKey, ToBech32};
use pool::{
- AgentPool, ControlSignal, IdleSwitchResult, OwnedAgent, PromptContext, PromptOutcome,
- PromptResult, PromptSource, SessionState, TimeoutKind,
+ AgentPool, ClaimOutcome, ControlSignal, ConversationSessionKey, IdleSwitchResult, OwnedAgent,
+ PromptContext, PromptOutcome, PromptResult, PromptSource, SessionState, TimeoutKind,
};
use queue::{CancelReason, EventQueue, FlushBatch, QueuedEvent, ThreadTags};
use relay::{HarnessRelay, RelayEventPublisher};
@@ -735,6 +735,11 @@ fn handle_relay_observer_control_event(
}
/// Handle a `cancel_turn` control frame: signal the in-flight task to cancel.
+///
+/// An optional `rootEventId` targets one thread scope exactly. Without it,
+/// the frame acts only when exactly one scope is in flight for the channel;
+/// with several, the target is ambiguous and nothing is touched
+/// (`status: "ambiguous_target"`). Control never fans out across roots.
fn handle_cancel_turn_control(
payload: &serde_json::Value,
pool: &mut AgentPool,
@@ -748,9 +753,29 @@ fn handle_cancel_turn_control(
tracing::warn!("observer cancel_turn control frame missing valid channelId");
return;
};
+ let root_event_id = payload
+ .get("rootEventId")
+ .and_then(|value| value.as_str())
+ .map(str::to_owned);
- let fired = signal_in_flight_task(pool, channel_id, ControlSignal::Cancel);
- let status = if fired { "sent" } else { "no_active_turn" };
+ let status = match resolve_control_frame_scope(pool, channel_id, root_event_id) {
+ Ok(Some(scope)) => {
+ if signal_in_flight_task(pool, &scope, ControlSignal::Cancel) {
+ "sent"
+ } else {
+ "no_active_turn"
+ }
+ }
+ Ok(None) => "no_active_turn",
+ Err(in_flight) => {
+ tracing::warn!(
+ channel_id = %channel_id,
+ in_flight,
+ "cancel_turn without rootEventId while multiple scopes in flight β refusing"
+ );
+ "ambiguous_target"
+ }
+ };
if let Some(observer) = observer {
observer.emit(
"control_result",
@@ -769,6 +794,26 @@ fn handle_cancel_turn_control(
}
}
+/// Resolve a control frame's target scope from its optional `rootEventId`.
+///
+/// `Some(root)` targets that thread scope exactly (in-flight or not β the
+/// signal send reports `no_active_turn` if it isn't). `None` falls back to
+/// the channel-level exactly-one rule; `Err(n)` reports `n` in-flight scopes
+/// (ambiguous β touch nothing).
+fn resolve_control_frame_scope(
+ pool: &AgentPool,
+ channel_id: Uuid,
+ root_event_id: Option,
+) -> Result
+ {unrecoverable && (
+
+ The session scope could not be applied or restored. Restart the
+ app to recover a consistent state.
+
+ )}
void setThreadScoped(value)}
/>
diff --git a/desktop/src/features/settings/ui/acpSessionScopeSetting.test.mjs b/desktop/src/features/settings/ui/acpSessionScopeSetting.test.mjs
index f020f64bb0..992b3d1eab 100644
--- a/desktop/src/features/settings/ui/acpSessionScopeSetting.test.mjs
+++ b/desktop/src/features/settings/ui/acpSessionScopeSetting.test.mjs
@@ -24,10 +24,15 @@ function harness(overrides = {}) {
calls,
deps: {
setBackend: async (scope) => calls.push(["backend", scope]),
+ getBackend: async () => {
+ calls.push(["read-backend"]);
+ return "thread";
+ },
listAgents: async () => [localRunning, remoteRunning, localStopped],
stopAgent: async (pubkey) => calls.push(["stop", pubkey]),
startAgent: async (pubkey) => calls.push(["start", pubkey]),
setUi: (enabled) => calls.push(["ui", enabled]),
+ onUnrecoverable: () => calls.push(["unrecoverable"]),
...overrides,
},
};
@@ -130,4 +135,81 @@ describe("ACP session scope setting", () => {
]);
assert.equal(secondStarts, 2);
});
+
+ it("reconciles UI and processes to the re-read authoritative scope when rollback persistence fails", async () => {
+ let backendCalls = 0;
+ const { calls, deps } = harness({
+ setBackend: async (scope) => {
+ calls.push(["backend", scope]);
+ backendCalls += 1;
+ if (backendCalls === 1) return; // apply write succeeds (thread persisted)
+ throw new Error("rollback persist failed");
+ },
+ startAgent: async (pubkey) => {
+ calls.push(["start", pubkey]);
+ if (calls.filter((c) => c[0] === "start").length === 1)
+ throw new Error("restart failed");
+ },
+ // Authoritative backend remains the NEW value (thread): the apply
+ // write landed and the rollback write failed.
+ getBackend: async () => {
+ calls.push(["read-backend"]);
+ return "thread";
+ },
+ });
+ await assert.rejects(
+ applyAcpSessionScopeSetting(false, true, deps),
+ /restart failed/,
+ );
+ // UI must land on the actual persisted scope (thread), never the
+ // assumed previous (channel), and processes reconcile after the read.
+ const readIndex = calls.findIndex((c) => c[0] === "read-backend");
+ const uiIndex = calls.findIndex((c) => c[0] === "ui");
+ assert.notEqual(readIndex, -1);
+ assert.deepEqual(calls[uiIndex], ["ui", true]);
+ assert.ok(readIndex < uiIndex, "authority read must precede UI commit");
+ const restartsAfterRead = calls
+ .slice(readIndex)
+ .filter((c) => c[0] === "stop" || c[0] === "start");
+ assert.deepEqual(restartsAfterRead, [
+ ["stop", "local"],
+ ["start", "local"],
+ ]);
+ assert.ok(!calls.some((c) => c[0] === "unrecoverable"));
+ });
+
+ it("surfaces hard recovery and touches nothing when rollback and authority read both fail", async () => {
+ let backendCalls = 0;
+ const { calls, deps } = harness({
+ setBackend: async (scope) => {
+ calls.push(["backend", scope]);
+ backendCalls += 1;
+ if (backendCalls === 1) return;
+ throw new Error("rollback persist failed");
+ },
+ startAgent: async (pubkey) => {
+ calls.push(["start", pubkey]);
+ if (calls.filter((c) => c[0] === "start").length === 1)
+ throw new Error("restart failed");
+ },
+ getBackend: async () => {
+ calls.push(["read-backend"]);
+ throw new Error("authority unreadable");
+ },
+ });
+ await assert.rejects(
+ applyAcpSessionScopeSetting(false, true, deps),
+ /restart failed/,
+ );
+ assert.ok(calls.some((c) => c[0] === "unrecoverable"));
+ // No UI claim and no process restarts under an unknown scope.
+ const readIndex = calls.findIndex((c) => c[0] === "read-backend");
+ assert.ok(!calls.some((c) => c[0] === "ui"));
+ assert.deepEqual(
+ calls
+ .slice(readIndex + 1)
+ .filter((c) => c[0] === "stop" || c[0] === "start"),
+ [],
+ );
+ });
});
diff --git a/desktop/src/features/settings/ui/acpSessionScopeSetting.ts b/desktop/src/features/settings/ui/acpSessionScopeSetting.ts
index 57a0044027..0e91e8ab95 100644
--- a/desktop/src/features/settings/ui/acpSessionScopeSetting.ts
+++ b/desktop/src/features/settings/ui/acpSessionScopeSetting.ts
@@ -6,10 +6,14 @@ export type SessionScopeAgent = {
export type SessionScopeDependencies = {
setBackend: (scope: "thread" | "channel") => Promise;
+ getBackend: () => Promise<"thread" | "channel">;
listAgents: () => Promise;
stopAgent: (pubkey: string) => Promise;
startAgent: (pubkey: string) => Promise;
setUi: (threadScoped: boolean) => void;
+ /** The authoritative backend scope could not be restored or read: the UI
+ * must surface a hard recovery state instead of claiming any scope. */
+ onUnrecoverable: () => void;
};
async function restartRunningLocalAgents(
@@ -25,8 +29,13 @@ async function restartRunningLocalAgents(
/**
* Apply the Rust-owned session-scope setting and restart affected processes. The UI is
- * committed only after every restart succeeds. On failure, both persisted
- * backend state and already-restarted agents are restored best-effort.
+ * committed only after every restart succeeds.
+ *
+ * Failure invariant: persisted setting, Rust in-memory value, UI, and affected
+ * processes must converge on one authoritative scope. Rollback is only claimed
+ * after the rollback write is confirmed; if that write fails, the authoritative
+ * value is re-read and UI/processes reconcile to it. If the authority cannot be
+ * read either, `onUnrecoverable` fires and nothing pretends to know the scope.
*/
export async function applyAcpSessionScopeSetting(
previous: boolean,
@@ -39,14 +48,31 @@ export async function applyAcpSessionScopeSetting(
await restartRunningLocalAgents(agents, deps);
deps.setUi(next);
} catch (error) {
+ // Establish the authoritative backend scope before touching UI or
+ // processes: preferably by restoring `previous`, otherwise by reading
+ // what actually persisted.
+ let authoritative: boolean;
try {
await deps.setBackend(previous ? "thread" : "channel");
+ authoritative = previous;
} catch (rollbackError) {
console.error(
"Failed to roll back ACP session-scope backend state",
rollbackError,
);
+ try {
+ authoritative = (await deps.getBackend()) === "thread";
+ } catch (readError) {
+ console.error(
+ "Failed to read authoritative ACP session scope after rollback failure",
+ readError,
+ );
+ deps.onUnrecoverable();
+ throw error;
+ }
}
+ // Reconcile every affected process under the confirmed authoritative
+ // scope β never under an assumed one.
for (const agent of agents) {
if (agent.status !== "running" || agent.backend.type !== "local")
continue;
@@ -60,7 +86,7 @@ export async function applyAcpSessionScopeSetting(
);
}
}
- deps.setUi(previous);
+ deps.setUi(authoritative);
throw error;
}
}
From 2983539742ae8d5749db92572da9e68617705117 Mon Sep 17 00:00:00 2001
From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
<011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Date: Sat, 18 Jul 2026 20:07:06 -0400
Subject: [PATCH 10/11] fix(desktop): treat failed process reconciliation as
hard recovery
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Mari's re-review: after a rollback/reconciliation pass, a failed
stopAgent leaves a process running under the wrong scope and a failed
startAgent leaves it stopped β committing a normal UI scope in either
case claims a convergence that does not exist. All process rollbacks
are still attempted, but any failure now surfaces onUnrecoverable
(card disables the toggle) instead of setUi.
Tests: the one-rollback-restart-fails case now asserts hard recovery
and no UI claim; new rollback-stop-failure case asserts the same.
Co-authored-by: Tyler Longwell
Signed-off-by: Tyler Longwell
---
.../ui/acpSessionScopeSetting.test.mjs | 30 +++++++++++++++++--
.../settings/ui/acpSessionScopeSetting.ts | 16 ++++++++--
2 files changed, 41 insertions(+), 5 deletions(-)
diff --git a/desktop/src/features/settings/ui/acpSessionScopeSetting.test.mjs b/desktop/src/features/settings/ui/acpSessionScopeSetting.test.mjs
index 992b3d1eab..376852c958 100644
--- a/desktop/src/features/settings/ui/acpSessionScopeSetting.test.mjs
+++ b/desktop/src/features/settings/ui/acpSessionScopeSetting.test.mjs
@@ -89,7 +89,7 @@ describe("ACP session scope setting", () => {
assert.equal(calls.at(-1)[1], false);
});
- it("attempts every process rollback even when one rollback restart fails", async () => {
+ it("attempts every process rollback and surfaces hard recovery when one rollback restart fails", async () => {
const first = {
pubkey: "first",
status: "running",
@@ -120,6 +120,8 @@ describe("ACP session scope setting", () => {
applyAcpSessionScopeSetting(false, true, deps),
/apply failed/,
);
+ // Every process rollback is still attempted, but a failed reconciliation
+ // must surface hard recovery instead of claiming a normal scope.
assert.deepEqual(calls, [
["backend", "thread"],
["stop", "first"],
@@ -131,9 +133,33 @@ describe("ACP session scope setting", () => {
["start", "first"],
["stop", "second"],
["start", "second"],
- ["ui", false],
+ ["unrecoverable"],
]);
assert.equal(secondStarts, 2);
+ assert.ok(!calls.some((c) => c[0] === "ui"));
+ });
+
+ it("surfaces hard recovery when a rollback stop fails", async () => {
+ let stops = 0;
+ const { calls, deps } = harness({
+ stopAgent: async (pubkey) => {
+ calls.push(["stop", pubkey]);
+ stops += 1;
+ if (stops === 2) throw new Error("rollback stop failed");
+ },
+ startAgent: async (pubkey) => {
+ calls.push(["start", pubkey]);
+ if (calls.filter((c) => c[0] === "start").length === 1)
+ throw new Error("apply failed");
+ },
+ });
+ await assert.rejects(
+ applyAcpSessionScopeSetting(false, true, deps),
+ /apply failed/,
+ );
+ // The process may still be running under the wrong scope: no UI claim.
+ assert.ok(calls.some((c) => c[0] === "unrecoverable"));
+ assert.ok(!calls.some((c) => c[0] === "ui"));
});
it("reconciles UI and processes to the re-read authoritative scope when rollback persistence fails", async () => {
diff --git a/desktop/src/features/settings/ui/acpSessionScopeSetting.ts b/desktop/src/features/settings/ui/acpSessionScopeSetting.ts
index 0e91e8ab95..2015961599 100644
--- a/desktop/src/features/settings/ui/acpSessionScopeSetting.ts
+++ b/desktop/src/features/settings/ui/acpSessionScopeSetting.ts
@@ -35,7 +35,8 @@ async function restartRunningLocalAgents(
* processes must converge on one authoritative scope. Rollback is only claimed
* after the rollback write is confirmed; if that write fails, the authoritative
* value is re-read and UI/processes reconcile to it. If the authority cannot be
- * read either, `onUnrecoverable` fires and nothing pretends to know the scope.
+ * read, or any process fails to reconcile under it, `onUnrecoverable` fires and
+ * nothing pretends to know the scope.
*/
export async function applyAcpSessionScopeSetting(
previous: boolean,
@@ -72,7 +73,11 @@ export async function applyAcpSessionScopeSetting(
}
}
// Reconcile every affected process under the confirmed authoritative
- // scope β never under an assumed one.
+ // scope β never under an assumed one. Any failure here means a process
+ // may still be running under the wrong scope (or not running at all),
+ // so convergence must not be claimed: finish every attempt, then
+ // surface hard recovery instead of committing a normal UI scope.
+ let reconciliationFailed = false;
for (const agent of agents) {
if (agent.status !== "running" || agent.backend.type !== "local")
continue;
@@ -80,13 +85,18 @@ export async function applyAcpSessionScopeSetting(
await deps.stopAgent(agent.pubkey);
await deps.startAgent(agent.pubkey);
} catch (rollbackError) {
+ reconciliationFailed = true;
console.error(
`Failed to roll back ACP session-scope process ${agent.pubkey}`,
rollbackError,
);
}
}
- deps.setUi(authoritative);
+ if (reconciliationFailed) {
+ deps.onUnrecoverable();
+ } else {
+ deps.setUi(authoritative);
+ }
throw error;
}
}
From dea538f516548cc38db1c2e7461efe84b21f2836 Mon Sep 17 00:00:00 2001
From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
<011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Date: Sat, 18 Jul 2026 20:17:16 -0400
Subject: [PATCH 11/11] test(acp): prove DM session-scope equivalence with
stream channels
DMs are private channels, not a separate scoping model. Pin that
contract with four tests in dispatch_scope_tests:
- scope derivation is channel-type-agnostic: identical tag shapes
derive identical root components on DM and stream channels in both
modes
- thread mode: two top-level DM messages are distinct concurrent
roots on distinct workers (context registers the channel as a DM)
- thread mode: a threaded DM reply joins its outermost root's batch
- channel mode: DM events collapse to (channel, None) and serialize,
second worker idle
Test-only; no production code touched.
Co-authored-by: Tyler Longwell
Signed-off-by: Tyler Longwell
---
crates/buzz-acp/src/lib.rs | 159 +++++++++++++++++++++++++++++++++++++
1 file changed, 159 insertions(+)
diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs
index c4eae8e809..c6b52f0d07 100644
--- a/crates/buzz-acp/src/lib.rs
+++ b/crates/buzz-acp/src/lib.rs
@@ -5548,6 +5548,165 @@ mod dispatch_scope_tests {
}
}
+ /// A `test_ctx` whose channel registry marks `ch` as a DM. Scope
+ /// derivation and dispatch must not consult `channel_type`, so every
+ /// assertion made against this context must match the stream-channel
+ /// behaviour pinned by the tests above.
+ fn test_ctx_dm(ch: Uuid) -> Arc {
+ let ctx = test_ctx();
+ let mut ctx = Arc::into_inner(ctx).expect("fresh ctx has a single owner");
+ ctx.channel_info.insert(
+ ch,
+ relay::ChannelInfo {
+ name: "dm".into(),
+ channel_type: "dm".into(),
+ },
+ );
+ Arc::new(ctx)
+ }
+
+ /// DM equivalence, part 0: scope derivation is channel-type-agnostic by
+ /// construction. For byte-identical tag shapes, a DM channel and a stream
+ /// channel derive the same root component in both modes β DMs are private
+ /// channels, not a separate scoping model.
+ #[test]
+ fn dm_and_stream_channels_derive_identical_scope_components() {
+ let mut config = build_mcp_servers_tests::test_config();
+ let dm_ch = Uuid::new_v4();
+ let stream_ch = Uuid::new_v4();
+
+ let cases: Vec> = vec![
+ // Top-level DM message: no thread tags at all.
+ vec![],
+ // Reply chain: explicit outermost root + intermediate parent.
+ vec![
+ nostr::Tag::parse(["e", "outer-root", "", "root"]).unwrap(),
+ nostr::Tag::parse(["e", "intermediate-parent", "", "reply"]).unwrap(),
+ ],
+ // Direct reply: reply tag only, no explicit root marker.
+ vec![nostr::Tag::parse(["e", "outer-root", "", "reply"]).unwrap()],
+ ];
+
+ for scope_mode in [config::SessionScope::Thread, config::SessionScope::Channel] {
+ config.session_scope = scope_mode;
+ for tags in &cases {
+ let event = tagged_event("same shape", tags.clone());
+ let id = event.id.to_hex();
+ let dm = conversation_scope_for_event(&config, &event, dm_ch, &id);
+ let stream = conversation_scope_for_event(&config, &event, stream_ch, &id);
+ assert_eq!(dm.channel_id, dm_ch);
+ assert_eq!(stream.channel_id, stream_ch);
+ assert_eq!(
+ dm.root_event_id, stream.root_event_id,
+ "root derivation must be identical across channel types (mode {scope_mode:?}, tags {tags:?})"
+ );
+ }
+ }
+ }
+
+ /// DM equivalence, thread mode: two top-level DM messages (no thread
+ /// tags β each is its own outermost root) derive distinct root scopes and
+ /// dispatch concurrently to distinct workers, exactly like two roots in a
+ /// stream channel. The context registers the channel as a DM to prove the
+ /// dispatch path never special-cases `channel_type`.
+ #[tokio::test]
+ async fn dm_thread_mode_top_level_messages_are_distinct_concurrent_roots() {
+ let mut config = build_mcp_servers_tests::test_config();
+ config.session_scope = config::SessionScope::Thread;
+ let mut pool =
+ AgentPool::from_slots(vec![Some(dummy_agent(0).await), Some(dummy_agent(1).await)]);
+ let mut queue = EventQueue::new(DedupMode::Queue);
+ let ch = Uuid::new_v4();
+
+ let first = tagged_event("dm one", []);
+ let second = tagged_event("dm two", []);
+ let first_root = first.id.to_hex();
+ let second_root = second.id.to_hex();
+ let first_scope = push_via_config_scope(&mut queue, &config, ch, first);
+ let second_scope = push_via_config_scope(&mut queue, &config, ch, second);
+
+ // Untagged events root at their own id: distinct scopes, never merged.
+ assert_eq!(first_scope, key(ch, &first_root));
+ assert_eq!(second_scope, key(ch, &second_root));
+ assert_ne!(first_scope, second_scope);
+
+ let dispatched = dispatch_pending(&mut pool, &mut queue, &test_ctx_dm(ch));
+
+ let scopes: HashSet<_> = dispatched.iter().map(|(s, _)| s.clone()).collect();
+ assert_eq!(
+ scopes,
+ HashSet::from([first_scope.clone(), second_scope.clone()])
+ );
+ assert!(queue.is_scope_in_flight(&first_scope));
+ assert!(queue.is_scope_in_flight(&second_scope));
+ assert!(!pool.any_idle(), "both workers must be running DM roots");
+ }
+
+ /// DM equivalence, thread mode: a threaded DM reply joins the session of
+ /// its outermost root, sharing one batch with the earlier message β same
+ /// join rule as `nested_reply_joins_outermost_root_session_scope`.
+ #[test]
+ fn dm_thread_mode_reply_joins_outermost_root_scope() {
+ let mut config = build_mcp_servers_tests::test_config();
+ config.session_scope = config::SessionScope::Thread;
+ let mut queue = EventQueue::new(DedupMode::Queue);
+ let ch = Uuid::new_v4();
+
+ let opener = tagged_event("dm opener", []);
+ let opener_root = opener.id.to_hex();
+ let reply = tagged_event(
+ "dm reply",
+ [
+ nostr::Tag::parse(["e", opener_root.as_str(), "", "root"]).unwrap(),
+ nostr::Tag::parse(["e", "intermediate-parent", "", "reply"]).unwrap(),
+ ],
+ );
+
+ let opener_scope = push_via_config_scope(&mut queue, &config, ch, opener);
+ let reply_scope = push_via_config_scope(&mut queue, &config, ch, reply);
+
+ assert_eq!(opener_scope, key(ch, &opener_root));
+ assert_eq!(reply_scope, opener_scope);
+ let batch = queue.flush_next().expect("shared DM root batch");
+ assert_eq!(batch.scope_key(), opener_scope);
+ assert_eq!(batch.events.len(), 2);
+ assert!(queue.flush_next().is_none());
+ }
+
+ /// DM equivalence, channel mode: distinct DM messages collapse to the
+ /// bare `(channel, None)` scope and serialize onto a single worker, with
+ /// the second worker idle β the legacy contract, unchanged for DMs.
+ #[tokio::test]
+ async fn dm_channel_mode_collapses_to_channel_scope_and_serializes() {
+ let mut config = build_mcp_servers_tests::test_config();
+ config.session_scope = config::SessionScope::Channel;
+ let mut pool =
+ AgentPool::from_slots(vec![Some(dummy_agent(0).await), Some(dummy_agent(1).await)]);
+ let mut queue = EventQueue::new(DedupMode::Queue);
+ let ch = Uuid::new_v4();
+
+ let top_level = tagged_event("dm top-level", []);
+ let threaded = tagged_event(
+ "dm threaded reply",
+ [nostr::Tag::parse(["e", "dm-root", "", "root"]).unwrap()],
+ );
+ for event in [top_level, threaded] {
+ assert_eq!(
+ push_via_config_scope(&mut queue, &config, ch, event),
+ ConversationSessionKey::channel(ch)
+ );
+ }
+
+ let dispatched = dispatch_pending(&mut pool, &mut queue, &test_ctx_dm(ch));
+ assert_eq!(dispatched.len(), 1);
+ assert_eq!(dispatched[0].0, ConversationSessionKey::channel(ch));
+ assert_eq!(pool.task_map().len(), 1);
+ assert!(
+ pool.any_idle(),
+ "second worker must remain idle in channel mode for DMs"
+ );
+ }
+
#[tokio::test]
async fn configured_channel_mode_serializes_distinct_thread_roots() {
let mut config = build_mcp_servers_tests::test_config();