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, usize> { + match root_event_id { + Some(root) => Ok(Some(ConversationSessionKey { + channel_id, + root_event_id: Some(root), + })), + None => resolve_channel_control_scope(pool, channel_id), + } +} + /// Handle a `switch_model` control frame (Phase 3a, Option ii). /// /// Busy path: deliver `SwitchModel` over the in-flight task's oneshot β€” the @@ -780,6 +825,11 @@ fn handle_cancel_turn_control( /// Idle path: validate against the cached catalog *before* invalidating /// (pre-cancel guard), then set `desired_model` + invalidate. The override /// takes visible effect on the agent's next turn. +/// +/// An optional `rootEventId` targets one thread scope exactly. Without it, +/// a busy channel is only switchable when exactly one scope is in flight; +/// with several, the target is ambiguous and nothing is touched +/// (`status: "ambiguous_target"`). Control never fans out across roots. fn handle_switch_model_control( payload: &serde_json::Value, pool: &mut AgentPool, @@ -797,35 +847,50 @@ fn handle_switch_model_control( tracing::warn!("observer switch_model control frame missing modelId"); return; }; - - // A turn is in flight for this channel iff a task_map entry exists. The - // agent is moved out of the pool during a turn, so the control oneshot is - // the only reachable lever; an idle channel has no such entry. - let turn_in_flight = pool - .task_map() - .values() - .any(|m| m.channel_id == Some(channel_id)); - - let status = if turn_in_flight { - // Busy path: deliver over the oneshot. `false` means the oneshot was - // already consumed this turn (a prior cancel/interrupt) β€” the turn is - // already ending, so the switch cannot land on it. - if signal_in_flight_task( - pool, - channel_id, - ControlSignal::SwitchModel(model_id.to_string()), - ) { - pool.switch_other_idle_channel_roots(channel_id, model_id); - "sent" - } else { - "turn_ending" + let root_event_id = payload + .get("rootEventId") + .and_then(|value| value.as_str()) + .map(str::to_owned); + + // A turn is in flight for a scope iff a task_map entry exists. The agent + // is moved out of the pool during a turn, so the control oneshot is the + // only reachable lever; an idle scope has no such entry. + let status = match resolve_control_frame_scope(pool, channel_id, root_event_id) { + Ok(Some(scope)) + if pool + .task_map() + .values() + .any(|m| m.scope.as_ref() == Some(&scope)) => + { + // Busy path: deliver over the oneshot. `false` means the oneshot + // was already consumed this turn (a prior cancel/interrupt) β€” the + // turn is already ending, so the switch cannot land on it. + if signal_in_flight_task( + pool, + &scope, + ControlSignal::SwitchModel(model_id.to_string()), + ) { + pool.switch_other_idle_channel_roots(channel_id, model_id); + "sent" + } else { + "turn_ending" + } } - } else { - // Idle path: validate against the cached catalog before invalidating. - match pool.switch_idle_agent_model(channel_id, model_id) { - IdleSwitchResult::Switched => "switched", - IdleSwitchResult::UnsupportedModel => "unsupported_model", - IdleSwitchResult::NoIdleAgent => "no_active_turn", + Ok(_) => { + // Idle path: validate against the cached catalog before invalidating. + match pool.switch_idle_agent_model(channel_id, model_id) { + IdleSwitchResult::Switched => "switched", + IdleSwitchResult::UnsupportedModel => "unsupported_model", + IdleSwitchResult::NoIdleAgent => "no_active_turn", + } + } + Err(in_flight) => { + tracing::warn!( + channel_id = %channel_id, + in_flight, + "switch_model without rootEventId while multiple scopes in flight β€” refusing" + ); + "ambiguous_target" } }; @@ -997,10 +1062,10 @@ struct RespawnResult { /// stream. /// /// Carries enough identity to operate on the right withheld event in -/// `EventQueue::withheld_native_steer`: `channel_id` is the routing key, +/// `EventQueue::withheld_native_steer`: `scope` is the routing key, /// `event_id` is the hex id of the single event the steer carried. struct SteerAckEvent { - channel_id: Uuid, + scope: ConversationSessionKey, event_id: String, /// `Ok` if the read loop sent any of the locked `SteerAck` variants. /// `Err` if the oneshot was dropped without a send β€” should not happen @@ -1515,7 +1580,7 @@ async fn tokio_main() -> Result<()> { } else { None }; - let mut typing_channels: HashMap = HashMap::new(); + let mut typing_scopes: HashMap = HashMap::new(); let mut presence_task: Option> = None; // Runs at the TOP of every loop iteration via Instant check β€” cannot be @@ -1652,8 +1717,8 @@ async fn tokio_main() -> Result<()> { // called on relay events or pool results, neither of which // arrive when the channel is silent. if queue.has_flushable_work() { - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { - typing_channels.insert(channel_id, thread_tags); + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + typing_scopes.insert(scope, thread_tags); } } } @@ -1688,8 +1753,8 @@ async fn tokio_main() -> Result<()> { // this, batches requeued during crash recovery sit idle until the // next relay event arrives β€” which can be minutes on quiet channels. if respawn_collected { - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { - typing_channels.insert(channel_id, thread_tags); + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + typing_scopes.insert(scope, thread_tags); } } @@ -1834,7 +1899,7 @@ async fn tokio_main() -> Result<()> { // Track removed channels so checked-out agents get // their sessions stripped when they return to the pool. removed_channels.insert(ch); - typing_channels.remove(&ch); + typing_scopes.retain(|scope, _| scope.channel_id != ch); // Best-effort: clean up πŸ‘€ on drained events. // Note: the relay revokes membership before // emitting the notification, so this DELETE may @@ -1908,16 +1973,44 @@ async fn tokio_main() -> Result<()> { if is_cancel { if let Some(owner) = owner_cache.get() { if buzz_event.event.pubkey.to_hex() == *owner { - let fired = signal_in_flight_task( - &mut pool, + // Resolve the target scope from the + // event's thread tags: a threaded + // !cancel hits its root exactly; a + // top-level one acts only when a + // single scope is in flight (never + // fan-out across roots). + match resolve_event_control_scope( + &pool, + &config, + &buzz_event.event, buzz_event.channel_id, - ControlSignal::Cancel, - ); - if !fired { - tracing::warn!( - channel_id = %buzz_event.channel_id, - "!cancel received but no in-flight task β€” no-op" - ); + ) { + Ok(Some(scope)) => { + if !signal_in_flight_task( + &mut pool, + &scope, + ControlSignal::Cancel, + ) { + tracing::warn!( + channel_id = %buzz_event.channel_id, + "!cancel received but no in-flight task β€” no-op" + ); + } + } + Ok(None) => { + tracing::warn!( + channel_id = %buzz_event.channel_id, + "!cancel received but no in-flight task β€” no-op" + ); + } + Err(in_flight) => { + tracing::warn!( + channel_id = %buzz_event.channel_id, + in_flight, + "top-level !cancel while multiple threads in flight β€” \ + ambiguous target, touching nothing (reply in the thread to cancel it)" + ); + } } continue; // consume event β€” do NOT push to queue } @@ -1946,23 +2039,46 @@ async fn tokio_main() -> Result<()> { if is_rotate { if let Some(owner) = owner_cache.get() { if buzz_event.event.pubkey.to_hex() == *owner { - let fired = signal_in_flight_task( - &mut pool, + // Same scope resolution as !cancel: a + // threaded !rotate hits its root; a + // top-level one needs an unambiguous + // (single or zero) in-flight target. + match resolve_event_control_scope( + &pool, + &config, + &buzz_event.event, buzz_event.channel_id, - ControlSignal::Rotate, - ); - if fired { - tracing::info!( - channel_id = %buzz_event.channel_id, - "!rotate received β€” cancelling in-flight turn and rotating session" - ); - } else { - let invalidated = pool.invalidate_channel_sessions(buzz_event.channel_id); - tracing::info!( - channel_id = %buzz_event.channel_id, - invalidated, - "!rotate received β€” invalidated idle channel session(s)" - ); + ) { + Ok(scope) => { + let fired = scope.is_some_and(|scope| { + signal_in_flight_task( + &mut pool, + &scope, + ControlSignal::Rotate, + ) + }); + if fired { + tracing::info!( + channel_id = %buzz_event.channel_id, + "!rotate received β€” cancelling in-flight turn and rotating session" + ); + } else { + let invalidated = pool.invalidate_channel_sessions(buzz_event.channel_id); + tracing::info!( + channel_id = %buzz_event.channel_id, + invalidated, + "!rotate received β€” invalidated idle channel session(s)" + ); + } + } + Err(in_flight) => { + tracing::warn!( + channel_id = %buzz_event.channel_id, + in_flight, + "top-level !rotate while multiple threads in flight β€” \ + ambiguous target, touching nothing (reply in the thread to rotate it)" + ); + } } continue; // consume event β€” do NOT push to queue } @@ -2026,17 +2142,22 @@ async fn tokio_main() -> Result<()> { // backed payload) so the cost is negligible. let event_for_steer = buzz_event.event.clone(); let prompt_tag_for_steer = prompt_tag.clone(); + let 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 { + None + }; + let scope = ConversationSessionKey { + channel_id: buzz_event.channel_id, + root_event_id: conversation_root.clone(), + }; let accepted = queue.push(QueuedEvent { channel_id: buzz_event.channel_id, event: buzz_event.event, received_at: std::time::Instant::now(), prompt_tag, - 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 { - None - }, + conversation_root, }); // πŸ‘€ β€” immediate "seen" reaction, only if the event // was actually queued (not dropped by DedupMode::Drop). @@ -2051,9 +2172,11 @@ async fn tokio_main() -> Result<()> { }); } // Event is already queued. If mode requires it AND - // the channel has an in-flight task, fire cancel β€” + // the scope has an in-flight task, fire cancel β€” // OR take the non-cancelling (ACP steer) fork for Steer signals. - if accepted && queue.is_channel_in_flight(buzz_event.channel_id) { + // Scope-exact: a busy sibling thread in the same + // channel never triggers a signal for this one. + if accepted && queue.is_scope_in_flight(&scope) { // Author eligibility (owner βˆͺ allowlist βˆͺ siblings) // is already enforced by the inbound author gate // above, so the mid-turn signal fires for every @@ -2080,7 +2203,7 @@ async fn tokio_main() -> Result<()> { && try_native_steer( &mut pool, &mut queue, - buzz_event.channel_id, + &scope, event_for_steer, prompt_tag_for_steer, &steer_ack_tx, @@ -2088,16 +2211,16 @@ async fn tokio_main() -> Result<()> { if !native_attempted { signal_in_flight_task( &mut pool, - buzz_event.channel_id, + &scope, signal, ); } } } - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { - typing_channels.insert(channel_id, thread_tags); + typing_scopes.insert(scope, thread_tags); } } None => { @@ -2120,10 +2243,10 @@ async fn tokio_main() -> Result<()> { let _ = result_rx; if queue.has_flushable_work() { tracing::debug!("heartbeat_skipped_events"); - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { - typing_channels.insert(channel_id, thread_tags); + typing_scopes.insert(scope, thread_tags); } } else if pool.any_idle() { dispatch_heartbeat(&mut pool, &ctx, &mut heartbeat_in_flight); @@ -2162,14 +2285,17 @@ async fn tokio_main() -> Result<()> { // Use try_publish (non-blocking) for typing indicators β€” // they're ephemeral and must not block the main loop during // relay reconnection (#35). - for (&ch, thread_tags) in &typing_channels { + for (scope, thread_tags) in &typing_scopes { if let Ok(event) = relay.build_typing_event( - ch, + scope.channel_id, thread_tags.root_event_id.as_deref(), thread_tags.parent_event_id.as_deref(), ) { if let Err(e) = relay.try_publish_event(event) { - tracing::debug!("typing indicator dropped for {ch}: {e}"); + tracing::debug!( + "typing indicator dropped for {}: {e}", + scope.channel_id + ); } } } @@ -2184,9 +2310,9 @@ async fn tokio_main() -> Result<()> { match pool_event { Some(PoolEvent::Result(result)) => { - // Stop typing indicator for the completed channel. + // Stop typing indicator for the completed scope. if let PromptSource::Channel(key) = &result.source { - typing_channels.remove(&key.channel_id); + typing_scopes.remove(key); } if handle_prompt_result( &mut pool, @@ -2210,7 +2336,7 @@ async fn tokio_main() -> Result<()> { &config, &mut heartbeat_in_flight, &removed_channels, - &mut typing_channels, + &mut typing_scopes, &mut crash_history, &respawn_tx, &mut respawn_tasks, @@ -2219,8 +2345,8 @@ async fn tokio_main() -> Result<()> { { break; } - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { - typing_channels.insert(channel_id, thread_tags); + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + typing_scopes.insert(scope, thread_tags); } } Some(PoolEvent::Panic(join_error)) => { @@ -2232,7 +2358,7 @@ async fn tokio_main() -> Result<()> { join_error, &mut heartbeat_in_flight, &removed_channels, - &mut typing_channels, + &mut typing_scopes, &mut crash_history, &respawn_tx, &mut respawn_tasks, @@ -2242,12 +2368,12 @@ async fn tokio_main() -> Result<()> { tracing::error!("all agents dead β€” exiting"); break; } - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { - typing_channels.insert(channel_id, thread_tags); + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + typing_scopes.insert(scope, thread_tags); } } Some(PoolEvent::SteerAck(SteerAckEvent { - channel_id, + scope, event_id, ack, })) => { @@ -2330,7 +2456,8 @@ async fn tokio_main() -> Result<()> { Err(_recv_err) => (true, false, false), }; tracing::info!( - channel = %channel_id, + channel = %scope.channel_id, + root = scope.root_event_id.as_deref().unwrap_or(""), event_id = %event_id, ?ack, release_withheld, @@ -2339,28 +2466,28 @@ async fn tokio_main() -> Result<()> { "non-cancelling steer ack received" ); if drop_withheld { - queue.remove_event(channel_id, &event_id); + queue.remove_event(&scope, &event_id); } if release_withheld { - queue.release_native_steer(channel_id, &event_id); + queue.release_native_steer(&scope, &event_id); } if signal_fallback { // Universal cancel+merge fallback. Note: the // queued event has already been released to the - // front of `queues[channel_id]`, so the cancel + // front of `queues[scope]`, so the cancel // will pick it up as part of the merged batch and // re-prompt the agent. - signal_in_flight_task(&mut pool, channel_id, ControlSignal::Steer); + signal_in_flight_task(&mut pool, &scope, ControlSignal::Steer); } // After releasing a withheld event, give dispatch a chance // to re-flush. If the prompt is still in flight, the - // channel stays `in_flight_channels` and `flush_next` + // scope stays `in_flight_scopes` and `flush_next` // skips it β€” but a Steer fallback signal sent above will // tear down the in-flight task; on its completion the // queue drains. We still try here in case the in-flight // task has already returned. - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { - typing_channels.insert(channel_id, thread_tags); + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + typing_scopes.insert(scope, thread_tags); } } None => {} // relay/heartbeat/shutdown branches handled inline above @@ -2519,21 +2646,26 @@ fn mode_gate_signal( } } -/// Send a control signal to the in-flight task for `channel_id`. +/// Send a control signal to the in-flight task for `scope`. /// Returns `true` if a signal was sent, `false` if no in-flight task was found. fn signal_in_flight_task( pool: &mut AgentPool, - channel_id: uuid::Uuid, + scope: &ConversationSessionKey, mode: ControlSignal, ) -> bool { let entry = pool .task_map_mut() .values_mut() - .find(|m| m.channel_id == Some(channel_id)); + .find(|m| m.scope.as_ref() == Some(scope)); if let Some(meta) = entry { if let Some(tx) = meta.control_tx.take() { - tracing::info!(channel = %channel_id, ?mode, "control signal sent to in-flight task"); + tracing::info!( + channel = %scope.channel_id, + root = scope.root_event_id.as_deref().unwrap_or(""), + ?mode, + "control signal sent to in-flight task" + ); let _ = tx.send(mode); return true; } @@ -2541,6 +2673,57 @@ fn signal_in_flight_task( false } +/// Resolve a channel-level control action (no explicit root) to a single +/// in-flight scope. +/// +/// Control never fans out across roots: with more than one scope in flight +/// for the channel the target is ambiguous and the caller must touch +/// nothing. `Ok(None)` means no turn is in flight at all. +fn resolve_channel_control_scope( + pool: &AgentPool, + channel_id: uuid::Uuid, +) -> Result, usize> { + let mut scopes = pool + .task_map() + .values() + .filter_map(|m| m.scope.as_ref()) + .filter(|scope| scope.channel_id == channel_id); + let Some(first) = scopes.next() else { + return Ok(None); + }; + let extra = scopes.count(); + if extra == 0 { + Ok(Some(first.clone())) + } else { + Err(extra + 1) + } +} + +/// Resolve the scope an inbound control event (owner `!cancel` / `!rotate`) +/// targets. +/// +/// Thread mode: a threaded event targets its outermost root exactly; a +/// top-level event falls back to the channel-level exactly-one rule via +/// [`resolve_channel_control_scope`]. Channel mode: always the channel scope. +fn resolve_event_control_scope( + pool: &AgentPool, + config: &Config, + event: &nostr::Event, + channel_id: uuid::Uuid, +) -> Result, usize> { + if matches!(config.session_scope, config::SessionScope::Thread) { + match queue::parse_thread_tags(event).root_event_id { + Some(root) => Ok(Some(ConversationSessionKey { + channel_id, + root_event_id: Some(root), + })), + None => resolve_channel_control_scope(pool, channel_id), + } + } else { + Ok(Some(ConversationSessionKey::channel(channel_id))) + } +} + /// Attempt the non-cancelling (ACP) steer for a freshly-queued event. /// /// Caller invariants: @@ -2568,11 +2751,12 @@ fn signal_in_flight_task( fn try_native_steer( pool: &mut AgentPool, queue: &mut EventQueue, - channel_id: uuid::Uuid, + scope: &ConversationSessionKey, event: nostr::Event, prompt_tag: String, steer_ack_tx: &mpsc::UnboundedSender, ) -> bool { + let channel_id = scope.channel_id; // Build the steer body: framing strings come from // `queue::native_steer_framing()` (Eva's drift-proof requirement β€” // native and cancel+merge fallback share these so the agent gets the @@ -2602,14 +2786,14 @@ fn try_native_steer( ack_tx, }; - match pool.send_steer(channel_id, request) { + match pool.send_steer(scope, request) { Ok(()) => { // Withhold the queued event synchronously BEFORE spawning // the watcher: this closes the race where `mark_complete` - // clears `in_flight_channels` and a stray `flush_next` could + // clears `in_flight_scopes` and a stray `flush_next` could // re-deliver the event via normal dispatch. See - // `EventQueue::mark_native_steer_pending` docs at queue.rs:606. - let withheld = queue.mark_native_steer_pending(channel_id, &event_id_hex); + // `EventQueue::mark_native_steer_pending` docs. + let withheld = queue.mark_native_steer_pending(scope, &event_id_hex); if !withheld { // Race: the event was already drained out of the queue // before we got here (e.g. a concurrent flush picked it @@ -2627,10 +2811,11 @@ fn try_native_steer( } let ack_tx_clone = steer_ack_tx.clone(); let event_id_for_watcher = event_id_hex.clone(); + let scope_for_watcher = scope.clone(); tokio::spawn(async move { let ack = ack_rx.await; let _ = ack_tx_clone.send(SteerAckEvent { - channel_id, + scope: scope_for_watcher, event_id: event_id_for_watcher, ack, }); @@ -2651,12 +2836,23 @@ fn try_native_steer( // ── dispatch_pending ────────────────────────────────────────────────────────── /// Flush queued work to available agents. +/// +/// Busy scopes don't block the loop: a batch whose retained root is owned by +/// a busy slot is requeued (timestamps preserved) and dispatch continues with +/// other scopes β€” head-of-line blocking across roots is exactly what +/// thread-scoped sessions exist to remove. Only pool exhaustion stops the +/// loop. fn dispatch_pending( pool: &mut AgentPool, queue: &mut EventQueue, ctx: &Arc, -) -> Vec<(Uuid, ThreadTags)> { - let mut dispatched_channels = Vec::new(); +) -> Vec<(ConversationSessionKey, ThreadTags)> { + let mut dispatched_scopes = Vec::new(); + // Batches whose retained root's owner is busy this pass. Held (scope + // still marked in-flight) so `flush_next` cannot re-select the scope β€” + // restoring it mid-loop would livelock on flush β†’ busy β†’ restore. + // Restored after the loop. + let mut busy_batches: Vec = Vec::new(); loop { let batch = match queue.flush_next() { Some(b) => b, @@ -2668,15 +2864,23 @@ fn dispatch_pending( .last() .map(|event| queue::parse_thread_tags(&event.event)) .unwrap_or_default(); - let session_key = pool::conversation_session_key(&batch); + let session_key = batch.scope_key(); let affinity_hit = pool.has_session_for(&session_key); let mut agent = match pool.try_claim(Some(&session_key)) { - Some(a) => a, - None => { + ClaimOutcome::Claimed(a) => a, + ClaimOutcome::BusyOwner => { + tracing::debug!( + channel = %channel_id, + root = session_key.root_event_id.as_deref().unwrap_or(""), + "busy_owner β€” holding scope, continuing dispatch" + ); + busy_batches.push(batch); + continue; + } + ClaimOutcome::Exhausted => { let pending = queue.pending_channels(); - tracing::debug!(pending_channels = pending, "pool_exhausted"); - queue.requeue_preserve_timestamps(batch); - queue.mark_complete(channel_id); + tracing::debug!(pending_scopes = pending, "pool_exhausted"); + busy_batches.push(batch); break; } }; @@ -2727,21 +2931,27 @@ fn dispatch_pending( abort_handle.id(), pool::TaskMeta { agent_index, - channel_id: Some(channel_id), + scope: Some(session_key.clone()), turn_id, recoverable_batch, control_tx: Some(control_tx), steer_tx, }, ); - dispatched_channels.push((channel_id, typing_scope)); + dispatched_scopes.push((session_key, typing_scope)); + } + // Return held busy/exhausted batches to the queue exactly as flushed. + for batch in busy_batches { + let scope = batch.scope_key(); + queue.restore_unclaimed(batch); + queue.mark_complete(&scope); } tracing::debug!( - dispatched = dispatched_channels.len(), + dispatched = dispatched_scopes.len(), queue_depth = queue.pending_channels(), "dispatch_pending" ); - dispatched_channels + dispatched_scopes } /// Spawn a task that posts a user-visible failure notice to the relay. @@ -2863,7 +3073,7 @@ fn handle_prompt_result( } match &result.source { - PromptSource::Channel(key) => queue.mark_complete(key.channel_id), + PromptSource::Channel(key) => queue.mark_complete(key), PromptSource::Heartbeat => *heartbeat_in_flight = false, } @@ -3095,7 +3305,7 @@ fn recover_panicked_agent( join_error: tokio::task::JoinError, heartbeat_in_flight: &mut bool, removed_channels: &HashSet, - typing_channels: &mut HashMap, + typing_scopes: &mut HashMap, crash_history: &mut [SlotCircuit], respawn_tx: &mpsc::Sender, respawn_tasks: &mut tokio::task::JoinSet<()>, @@ -3110,25 +3320,25 @@ fn recover_panicked_agent( // Requeue BEFORE mark_complete (same rationale as handle_prompt_result). if let Some(batch) = meta.recoverable_batch { - if let Some(ch) = meta.channel_id { - if !removed_channels.contains(&ch) { + if let Some(ref scope) = meta.scope { + if !removed_channels.contains(&scope.channel_id) { // Dead-letter on exhaustion is logged inside requeue(); a // panic path has no outcome to report, so no notice here. let _ = queue.requeue(batch); tracing::warn!("requeued batch for panicked agent {i}"); } else { tracing::debug!( - channel_id = %ch, + channel_id = %scope.channel_id, "dropping panicked batch for removed channel" ); } } } - if let Some(ch) = meta.channel_id { - queue.mark_complete(ch); - typing_channels.remove(&ch); - tracing::warn!("cleared wedged in-flight channel {ch} from panicked agent {i}"); + if let Some(ref scope) = meta.scope { + queue.mark_complete(scope); + typing_scopes.remove(scope); + tracing::warn!("cleared wedged in-flight scope {scope:?} from panicked agent {i}"); } else { *heartbeat_in_flight = false; tracing::warn!("cleared wedged heartbeat_in_flight from panicked agent {i}"); @@ -3138,7 +3348,11 @@ fn recover_panicked_agent( observer.emit( "agent_panic", Some(i), - &observer::context_for(meta.channel_id, None, Some(meta.turn_id)), + &observer::context_for( + meta.scope.as_ref().map(|s| s.channel_id), + None, + Some(meta.turn_id), + ), serde_json::json!({ "outcome": "panic", "error": format!("Agent task panicked: {join_error}"), @@ -3193,7 +3407,7 @@ fn drain_ready_join_results( config: &Config, heartbeat_in_flight: &mut bool, removed_channels: &HashSet, - typing_channels: &mut HashMap, + typing_scopes: &mut HashMap, crash_history: &mut [SlotCircuit], respawn_tx: &mpsc::Sender, respawn_tasks: &mut tokio::task::JoinSet<()>, @@ -3209,7 +3423,7 @@ fn drain_ready_join_results( join_error, heartbeat_in_flight, removed_channels, - typing_channels, + typing_scopes, crash_history, respawn_tx, respawn_tasks, @@ -3232,8 +3446,8 @@ fn dispatch_heartbeat( return; } let agent = match pool.try_claim(None) { - Some(a) => a, - None => return, + pool::ClaimOutcome::Claimed(a) => a, + pool::ClaimOutcome::BusyOwner | pool::ClaimOutcome::Exhausted => return, }; let prompt_text = ctx @@ -3263,7 +3477,7 @@ fn dispatch_heartbeat( abort_handle.id(), pool::TaskMeta { agent_index, - channel_id: None, + scope: None, turn_id, recoverable_batch: None, control_tx: None, @@ -3842,8 +4056,8 @@ mod owner_control_command_tests { #[tokio::test] async fn signal_in_flight_task_sends_rotate_once() { let mut pool = AgentPool::from_slots(vec![]); - let channel_id = Uuid::new_v4(); - let other_channel_id = Uuid::new_v4(); + let scope = ConversationSessionKey::channel(Uuid::new_v4()); + let other_scope = ConversationSessionKey::channel(Uuid::new_v4()); let (control_tx, control_rx) = tokio::sync::oneshot::channel(); let abort_handle = pool.join_set.spawn(async {}); @@ -3851,7 +4065,7 @@ mod owner_control_command_tests { abort_handle.id(), pool::TaskMeta { agent_index: 0, - channel_id: Some(channel_id), + scope: Some(scope.clone()), turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: Some(control_tx), @@ -3861,18 +4075,18 @@ mod owner_control_command_tests { assert!(!signal_in_flight_task( &mut pool, - other_channel_id, + &other_scope, ControlSignal::Rotate )); assert!(signal_in_flight_task( &mut pool, - channel_id, + &scope, ControlSignal::Rotate )); assert_eq!(control_rx.await.unwrap(), ControlSignal::Rotate); assert!(!signal_in_flight_task( &mut pool, - channel_id, + &scope, ControlSignal::Rotate )); } @@ -4143,7 +4357,9 @@ mod build_mcp_servers_tests { /// Env-var-touching tests must run serially β€” env vars are process-global. static ENV_LOCK: Mutex<()> = Mutex::new(()); - fn test_config() -> Config { + /// `pub(super)` so sibling test modules (e.g. `dispatch_scope_tests`) + /// can borrow a baseline Config instead of duplicating the literal. + pub(super) fn test_config() -> Config { Config { keys: nostr::Keys::generate(), relay_url: "ws://localhost:3000".into(), @@ -4402,6 +4618,7 @@ mod error_outcome_emission_tests { let mut owner = pool .try_claim(Some(&key)) + .claimed() .expect("root should reserve slot 0"); assert_eq!(owner.index, 0); let task_id = pool.join_set.spawn(async {}).id(); @@ -4409,7 +4626,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: Some(key.channel_id), + scope: Some(key.clone()), turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -4418,13 +4635,13 @@ mod error_outcome_emission_tests { ); assert!(pool.any_idle(), "slot 1 remains idle"); assert!( - pool.try_claim(Some(&key)).is_none(), + matches!(pool.try_claim(Some(&key)), pool::ClaimOutcome::BusyOwner), "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); + assert_eq!(pool.try_claim(Some(&key)).claimed().unwrap().index, 0); } async fn pool_with_retained_root() -> (AgentPool, pool::ConversationSessionKey) { @@ -4435,7 +4652,7 @@ mod error_outcome_emission_tests { channel_id: Uuid::new_v4(), root_event_id: Some("root-a".into()), }; - let mut owner = pool.try_claim(Some(&key)).unwrap(); + let mut owner = pool.try_claim(Some(&key)).claimed().unwrap(); owner.state.insert_session(key.clone(), "session-a".into()); pool.return_agent(owner); (pool, key) @@ -4444,7 +4661,7 @@ mod error_outcome_emission_tests { #[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(); + let mut owner = pool.try_claim(Some(&key)).claimed().unwrap(); for index in 0..=pool::SessionState::MAX_CHANNEL_SESSIONS { owner.state.insert_session( pool::ConversationSessionKey { @@ -4457,7 +4674,7 @@ mod error_outcome_emission_tests { 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); + assert_eq!(pool.try_claim(Some(&key)).claimed().unwrap().index, 1); } #[tokio::test] @@ -4465,14 +4682,14 @@ mod error_outcome_emission_tests { 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); + assert_eq!(pool.try_claim(Some(&key)).claimed().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); + assert_eq!(pool.try_claim(Some(&key)).claimed().unwrap().index, 1); } #[tokio::test] @@ -4489,7 +4706,7 @@ mod error_outcome_emission_tests { }) .collect(); for key in &keys { - let mut owner = pool.try_claim(Some(key)).unwrap(); + let mut owner = pool.try_claim(Some(key)).claimed().unwrap(); owner .state .insert_session(key.clone(), format!("session-{key:?}")); @@ -4498,10 +4715,10 @@ mod error_outcome_emission_tests { pool.agents_mut()[0] = None; // 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(); + let first_claim = pool.try_claim(Some(&keys[0])).claimed().unwrap(); assert_eq!(first_claim.index, 1); pool.return_agent(first_claim); - assert_eq!(pool.try_claim(Some(&keys[1])).unwrap().index, 1); + assert_eq!(pool.try_claim(Some(&keys[1])).claimed().unwrap().index, 1); } #[tokio::test] @@ -4552,7 +4769,7 @@ mod error_outcome_emission_tests { 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(); + let busy = pool.try_claim(Some(&busy_key)).claimed().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)); @@ -4575,7 +4792,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -4651,7 +4868,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: Some(channel_id), + scope: Some(ConversationSessionKey::channel(channel_id)), turn_id: "panic-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -4740,7 +4957,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -4828,7 +5045,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -4868,7 +5085,7 @@ mod error_outcome_emission_tests { ); ( queue.pending_channels(), - queue.queued_event_count(&channel_id), + queue.queued_event_count(&ConversationSessionKey::channel(channel_id)), ) }; @@ -4941,7 +5158,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -5081,7 +5298,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -5181,6 +5398,294 @@ mod error_outcome_emission_tests { } } +#[cfg(test)] +mod dispatch_scope_tests { + //! Pins the concurrency contract of thread-scoped dispatch: + //! + //! - Distinct roots in one channel dispatch to distinct workers in one + //! `dispatch_pending` pass β€” no cross-root head-of-line blocking. + //! - A retained root whose owning slot is busy is skipped (batch restored + //! losslessly), while other scopes keep dispatching. The root never + //! migrates to another slot. + //! - Pool exhaustion restores the undispatched batch losslessly. + + use super::*; + use crate::acp::AcpClient; + use crate::pool::{AgentPool, OwnedAgent, PromptContext}; + use crate::queue::QueuedEvent; + use nostr::{EventBuilder, Keys, Kind}; + + async fn dummy_agent(index: usize) -> OwnedAgent { + OwnedAgent { + index, + acp: AcpClient::spawn("cat", &[], &[], false) + .await + .expect("spawn cat as inert agent"), + state: Default::default(), + model_capabilities: None, + desired_model: None, + model_overridden: false, + agent_name: "unknown".into(), + goose_system_prompt_supported: None, + protocol_version: 1, + } + } + + fn test_ctx() -> Arc { + let keys = nostr::Keys::generate(); + Arc::new(PromptContext { + mcp_servers: vec![], + initial_message: None, + idle_timeout: Duration::from_secs(60), + max_turn_duration: Duration::from_secs(120), + turn_liveness_interval: Duration::ZERO, + dedup_mode: DedupMode::Queue, + system_prompt: None, + team_instructions: None, + heartbeat_prompt: None, + base_prompt: None, + cwd: ".".to_string(), + rest_client: relay::RestClient { + http: reqwest::Client::new(), + base_url: "http://127.0.0.1:0".to_string(), + keys: keys.clone(), + auth_tag_json: None, + }, + channel_info: std::collections::HashMap::new(), + context_message_limit: 0, + max_turns_per_session: 0, + permission_mode: config::PermissionMode::Default, + agent_keys: keys, + agent_owner_pubkey: None, + memory_enabled: false, + harness_name: "goose".to_string(), + }) + } + + fn push_rooted(queue: &mut EventQueue, ch: Uuid, root: &str, content: &str) { + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), content) + .tags([]) + .sign_with_keys(&keys) + .unwrap(); + queue.push(QueuedEvent { + channel_id: ch, + conversation_root: Some(root.into()), + event, + received_at: std::time::Instant::now(), + prompt_tag: "test".into(), + }); + } + + fn key(ch: Uuid, root: &str) -> ConversationSessionKey { + ConversationSessionKey { + channel_id: ch, + root_event_id: Some(root.into()), + } + } + + #[tokio::test] + async fn two_roots_in_one_channel_dispatch_concurrently_to_distinct_workers() { + 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(); + push_rooted(&mut queue, ch, "root-a", "for a"); + push_rooted(&mut queue, ch, "root-b", "for b"); + + let dispatched = dispatch_pending(&mut pool, &mut queue, &test_ctx()); + + let scopes: Vec<_> = dispatched.iter().map(|(s, _)| s.clone()).collect(); + assert!(scopes.contains(&key(ch, "root-a"))); + assert!(scopes.contains(&key(ch, "root-b"))); + assert!(queue.is_scope_in_flight(&key(ch, "root-a"))); + assert!(queue.is_scope_in_flight(&key(ch, "root-b"))); + // Distinct workers: both slots checked out, distinct task scopes. + assert!(!pool.any_idle()); + let task_scopes: HashSet<_> = pool + .task_map() + .values() + .filter_map(|m| m.scope.clone()) + .collect(); + assert_eq!(task_scopes.len(), 2); + } + + #[tokio::test] + async fn busy_root_owner_is_skipped_without_blocking_other_scopes() { + 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(); + + // Slot 0 retains root-a's session… + let mut owner = pool + .try_claim(Some(&key(ch, "root-a"))) + .claimed() + .expect("claim slot 0 for root-a"); + owner + .state + .insert_session(key(ch, "root-a"), "session-a".into()); + pool.return_agent(owner); + // …and is then checked out busy on an unrelated scope. + let busy = pool + .try_claim(Some(&key(ch, "root-busy"))) + .claimed() + .expect("slot 0 is first idle"); + assert_eq!(busy.index, 0); + let task_id = pool.join_set.spawn(std::future::pending()).id(); + pool.task_map_mut().insert( + task_id, + pool::TaskMeta { + agent_index: 0, + scope: Some(key(ch, "root-busy")), + turn_id: "busy-turn".into(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + }, + ); + drop(busy); // stays checked out β€” slot 0 empty + + push_rooted(&mut queue, ch, "root-a", "must wait for owner"); + push_rooted(&mut queue, ch, "root-b", "must not be blocked"); + + let dispatched = dispatch_pending(&mut pool, &mut queue, &test_ctx()); + + // root-b dispatched (on slot 1) despite root-a being older and busy. + let scopes: Vec<_> = dispatched.iter().map(|(s, _)| s.clone()).collect(); + assert_eq!(scopes, vec![key(ch, "root-b")]); + // root-a restored losslessly: queued again, not in flight, and NOT + // migrated to slot 1. + assert!(!queue.is_scope_in_flight(&key(ch, "root-a"))); + assert_eq!(queue.queued_event_count(&key(ch, "root-a")), 1); + assert!( + pool.task_map() + .values() + .all(|m| m.scope != Some(key(ch, "root-a"))), + "busy-owned root must never run on another slot" + ); + } + + #[tokio::test] + async fn pool_exhaustion_restores_undispatched_batch_losslessly() { + let mut pool = AgentPool::from_slots(vec![Some(dummy_agent(0).await)]); + let mut queue = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + push_rooted(&mut queue, ch, "root-a", "gets the only agent"); + push_rooted(&mut queue, ch, "root-b", "waits for capacity"); + + let dispatched = dispatch_pending(&mut pool, &mut queue, &test_ctx()); + + let scopes: Vec<_> = dispatched.iter().map(|(s, _)| s.clone()).collect(); + assert_eq!(scopes, vec![key(ch, "root-a")]); + assert!(!queue.is_scope_in_flight(&key(ch, "root-b"))); + assert_eq!(queue.queued_event_count(&key(ch, "root-b")), 1); + // Next pass with a freed agent picks root-b up. + pool.agents_mut()[0] = Some(dummy_agent(0).await); + let next = dispatch_pending(&mut pool, &mut queue, &test_ctx()); + // Note: root-a is still in flight, so only root-b dispatches. + let next_scopes: Vec<_> = next.iter().map(|(s, _)| s.clone()).collect(); + assert_eq!(next_scopes, vec![key(ch, "root-b")]); + } + + // ── control-scope resolution ───────────────────────────────────────── + + fn insert_in_flight_scope(pool: &mut AgentPool, scope: ConversationSessionKey) { + let task_id = pool.join_set.spawn(std::future::pending()).id(); + let agent_index = pool.task_map().len(); + pool.task_map_mut().insert( + task_id, + pool::TaskMeta { + agent_index, + scope: Some(scope), + turn_id: Uuid::new_v4().to_string(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + }, + ); + } + + #[tokio::test] + async fn channel_control_resolves_only_when_exactly_one_scope_in_flight() { + let mut pool = AgentPool::from_slots(vec![]); + let ch = Uuid::new_v4(); + + // No turns: nothing to act on. + assert_eq!(resolve_channel_control_scope(&pool, ch), Ok(None)); + + // Exactly one: unambiguous. + insert_in_flight_scope(&mut pool, key(ch, "root-a")); + assert_eq!( + resolve_channel_control_scope(&pool, ch), + Ok(Some(key(ch, "root-a"))) + ); + + // Two scopes in the channel: ambiguous, touch nothing. + insert_in_flight_scope(&mut pool, key(ch, "root-b")); + assert_eq!(resolve_channel_control_scope(&pool, ch), Err(2)); + + // A busy scope in a DIFFERENT channel never bleeds in. + let other = Uuid::new_v4(); + assert_eq!(resolve_channel_control_scope(&pool, other), Ok(None)); + } + + #[tokio::test] + async fn control_frame_root_event_id_targets_one_scope_exactly() { + let mut pool = AgentPool::from_slots(vec![]); + let ch = Uuid::new_v4(); + insert_in_flight_scope(&mut pool, key(ch, "root-a")); + insert_in_flight_scope(&mut pool, key(ch, "root-b")); + + // Explicit root bypasses the ambiguity rule. + assert_eq!( + resolve_control_frame_scope(&pool, ch, Some("root-b".into())), + Ok(Some(key(ch, "root-b"))) + ); + // Without it, two in-flight scopes are ambiguous. + assert_eq!(resolve_control_frame_scope(&pool, ch, None), Err(2)); + } + + #[tokio::test] + async fn threaded_event_control_targets_its_own_root() { + let mut pool = AgentPool::from_slots(vec![]); + let ch = Uuid::new_v4(); + insert_in_flight_scope(&mut pool, key(ch, "aaaa")); + insert_in_flight_scope(&mut pool, key(ch, "bbbb")); + + let mut config = build_mcp_servers_tests::test_config(); + config.session_scope = config::SessionScope::Thread; + + // A reply inside thread bbbb targets bbbb exactly β€” never fans out. + let keys = Keys::generate(); + let threaded = EventBuilder::new(Kind::Custom(9), "!cancel") + .tags([nostr::Tag::parse(["e", "bbbb", "", "root"]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + assert_eq!( + resolve_event_control_scope(&pool, &config, &threaded, ch), + Ok(Some(key(ch, "bbbb"))) + ); + + // A top-level !cancel with two threads in flight is ambiguous. + let top_level = EventBuilder::new(Kind::Custom(9), "!cancel") + .tags([]) + .sign_with_keys(&keys) + .unwrap(); + assert_eq!( + resolve_event_control_scope(&pool, &config, &top_level, ch), + Err(2) + ); + + // Channel mode: always the channel scope, regardless of threads. + config.session_scope = config::SessionScope::Channel; + assert_eq!( + resolve_event_control_scope(&pool, &config, &threaded, ch), + Ok(Some(ConversationSessionKey::channel(ch))) + ); + } +} + #[cfg(test)] mod observer_payload_trim_tests { use super::*; diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 25c41a351d..7a8cf5e7f8 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -40,13 +40,18 @@ use crate::queue::{ }; use crate::relay::{ChannelInfo, RestClient}; +// Scheduling/session identity lives in queue.rs next to the scope-keyed +// queue state machine; re-exported here so pool consumers keep one name. +pub use crate::queue::ConversationSessionKey; + // FlushBatch and BatchEvent derive Clone (added in queue.rs) so we can store // a recoverable copy in TaskMeta for panic recovery in Queue mode. /// Metadata stored per in-flight task for panic recovery. pub struct TaskMeta { pub agent_index: usize, - pub channel_id: Option, + /// Conversation scope of the in-flight prompt; `None` for heartbeats. + pub scope: Option, /// Identifies terminal events when the task panics before returning a result. pub turn_id: String, /// Clone of batch for Queue mode panic recovery. @@ -74,22 +79,6 @@ 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 @@ -579,25 +568,32 @@ impl AgentPool { } } - /// Try to claim an idle agent for the given channel (or heartbeat if `None`). + /// Try to claim an idle agent for the given scope (or heartbeat if `None`). /// - /// Pass 1: prefer an agent that already has a session for `channel_id`. + /// Pass 1: prefer an agent that already has a session for the scope. /// Pass 2: any idle agent. /// - /// Returns `None` if all agents are checked out. - pub fn try_claim( - &mut self, - session_key: Option<&ConversationSessionKey>, - ) -> Option { + /// Retained roots have strict slot affinity: if the root's owning slot is + /// busy, the claim returns [`ClaimOutcome::BusyOwner`] so the dispatcher + /// can skip this scope and keep dispatching other scopes β€” a busy owner + /// must not stall unrelated roots, and the root must never migrate to + /// another slot (that would fork the provider session). + /// [`ClaimOutcome::Exhausted`] means no idle agent exists at all, so the + /// dispatch loop should stop. + pub fn try_claim(&mut self, session_key: Option<&ConversationSessionKey>) -> ClaimOutcome { // 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() { self.prune_session_owners(); if let Some(&owner) = self.session_owners.get(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); + // Surviving the prune means the owner is busy (skip this + // scope, keep dispatching others) or idle and retaining + // the session. + return match self.agents.get_mut(owner).and_then(Option::take) { + Some(agent) => ClaimOutcome::Claimed(agent), + None => ClaimOutcome::BusyOwner, + }; } } let idx = self.agents.iter().position(|slot| { @@ -607,17 +603,21 @@ impl AgentPool { }); if let Some(i) = idx { self.session_owners.insert(key.clone(), i); - return self.agents[i].take(); + return ClaimOutcome::Claimed( + self.agents[i].take().expect("position matched idle slot"), + ); } } // 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())?; + let Some(idx) = self.agents.iter().position(|slot| slot.is_some()) else { + return ClaimOutcome::Exhausted; + }; 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() + ClaimOutcome::Claimed(self.agents[idx].take().expect("position matched idle slot")) } /// Drop root reservations that no longer bind: the owning slot is neither @@ -703,19 +703,19 @@ impl AgentPool { /// watcher, to close the result-vs-ack race. /// /// Returns `Err(SteerError::PromptCompleted)` if no task is in flight - /// for `channel_id` (the prompt completed between the mode-gate check - /// and this call, or the channel was never in flight). This is + /// for `scope` (the prompt completed between the mode-gate check + /// and this call, or the scope was never in flight). This is /// semantically a soft no-op β€” the caller should release any withheld /// event and let normal dispatch handle delivery. pub fn send_steer( &mut self, - channel_id: Uuid, + scope: &ConversationSessionKey, request: SteerRequest, ) -> Result<(), SteerError> { let meta = self .task_map .values_mut() - .find(|m| m.channel_id == Some(channel_id)) + .find(|m| m.scope.as_ref() == Some(scope)) .ok_or(SteerError::PromptCompleted)?; let tx = meta .steer_tx @@ -855,6 +855,33 @@ impl AgentPool { } } +/// Outcome of [`AgentPool::try_claim`]. +// The variant size skew is fine: a ClaimOutcome is matched and consumed +// immediately at the claim site, never stored β€” boxing would add churn +// on the hot dispatch path for no benefit. +#[allow(clippy::large_enum_variant)] +pub enum ClaimOutcome { + /// An idle agent was checked out for the scope. + Claimed(OwnedAgent), + /// The scope is a retained root whose owning slot is busy on another + /// turn. Skip this scope β€” do NOT stop the dispatch loop, and do NOT + /// run the root on a different slot (that would fork its session). + BusyOwner, + /// No idle agent in the pool. Stop the dispatch loop. + Exhausted, +} + +impl ClaimOutcome { + /// The claimed agent, if any. Test-only convenience. + #[cfg(test)] + pub fn claimed(self) -> Option { + match self { + ClaimOutcome::Claimed(agent) => Some(agent), + ClaimOutcome::BusyOwner | ClaimOutcome::Exhausted => None, + } + } +} + /// Outcome of [`AgentPool::switch_idle_agent_model`]. #[derive(Debug, PartialEq, Eq)] pub enum IdleSwitchResult { @@ -1340,16 +1367,6 @@ fn send_prompt_result( }); } -/// 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: batch.conversation_root.clone(), - } -} - /// Core async function spawned for each prompt. /// /// Lifecycle: @@ -1373,7 +1390,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)), + Some(b) => PromptSource::Channel(b.scope_key()), None => PromptSource::Heartbeat, }; let observer_channel_id = match &source { @@ -4557,7 +4574,7 @@ mod tests { #[test] fn test_conversation_session_key_is_channel_scoped_without_root() { let batch = one_event_batch(Uuid::new_v4()); - let key = conversation_session_key(&batch); + let key = batch.scope_key(); assert_eq!(key.channel_id, batch.channel_id); assert_eq!(key.root_event_id, None); } @@ -4566,7 +4583,7 @@ mod tests { 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); + let key = batch.scope_key(); assert_eq!(key.channel_id, batch.channel_id); assert_eq!(key.root_event_id.as_deref(), Some("root-a")); } diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index a07ce5278a..7f12197ff1 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -20,8 +20,8 @@ use uuid::Uuid; use crate::config::DedupMode; -/// Maximum events queued per channel before oldest events are dropped. -const MAX_PENDING_PER_CHANNEL: usize = 500; +/// Maximum events queued per conversation scope before oldest events are dropped. +const MAX_PENDING_PER_SCOPE: usize = 500; /// Maximum events drained into a single batch. const MAX_BATCH_EVENTS: usize = 50; @@ -41,6 +41,29 @@ const IN_FLIGHT_DEADLINE_BUFFER_SECS: u64 = 100; /// Default in-flight deadline: default max_turn (7200s) + 100s buffer. const DEFAULT_IN_FLIGHT_DEADLINE_SECS: u64 = 7300; +/// Scheduling and session identity for one conversation scope. +/// +/// `root_event_id = None` β†’ channel scope (legacy): the whole channel is one +/// scope. `Some(root)` β†’ thread scope: one scope per outermost thread root. +/// All queue bookkeeping, in-flight tracking, retries, session affinity, and +/// control routing key off this β€” channel-scoped mode is simply the +/// degenerate single-scope-per-channel case. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ConversationSessionKey { + pub channel_id: Uuid, + pub root_event_id: Option, +} + +impl ConversationSessionKey { + /// Channel-scoped key (legacy mode / heartbeat-adjacent paths). + pub fn channel(channel_id: Uuid) -> Self { + Self { + channel_id, + root_event_id: None, + } + } +} + /// An event waiting in the queue. #[derive(Debug, Clone)] pub struct QueuedEvent { @@ -49,10 +72,20 @@ 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. + /// Outermost thread root when thread-scoped sessions are enabled; + /// `None` in channel-scoped mode. pub conversation_root: Option, } +impl QueuedEvent { + fn scope_key(&self) -> ConversationSessionKey { + ConversationSessionKey { + channel_id: self.channel_id, + root_event_id: self.conversation_root.clone(), + } + } +} + /// A single event inside a [`FlushBatch`]. #[derive(Debug, Clone)] pub struct BatchEvent { @@ -92,84 +125,100 @@ pub struct FlushBatch { pub cancel_reason: Option, } +impl FlushBatch { + /// The conversation scope this batch belongs to. + pub fn scope_key(&self) -> ConversationSessionKey { + ConversationSessionKey { + channel_id: self.channel_id, + root_event_id: self.conversation_root.clone(), + } + } +} + #[derive(Debug, Clone)] struct CancelledBatch { - conversation_root: Option, events: Vec, reason: CancelReason, } -/// Per-channel event queue with per-channel in-flight enforcement. +/// Per-scope event queue with per-scope in-flight enforcement. +/// +/// A "scope" is a [`ConversationSessionKey`]: the whole channel in channel +/// mode (`root_event_id = None`), or one outermost thread root in thread +/// mode. Every piece of bookkeeping below is scope-keyed, so channel mode is +/// the degenerate one-scope-per-channel case and needs no special paths. /// /// # State Machine /// /// ```text /// State: -/// queues: Map> (capped at MAX_PENDING_PER_CHANNEL) -/// in_flight_channels: HashSet -/// in_flight_deadlines: Map (auto-expire after in_flight_deadline) -/// retry_after: Map -/// retry_counts: Map (dead-letter after MAX_RETRIES) +/// queues: Map> (capped at MAX_PENDING_PER_SCOPE) +/// in_flight_scopes: HashSet +/// in_flight_deadlines: Map (auto-expire after in_flight_deadline) +/// retry_after: Map +/// retry_counts: Map (dead-letter after MAX_RETRIES) /// dedup_mode: DedupMode /// /// Transitions: /// push(event): -/// if dedup_mode == Drop AND in_flight_channels.contains(event.channel_id): +/// if dedup_mode == Drop AND in_flight_scopes.contains(event.scope): /// debug log + discard -/// else if queues[channel].len() >= MAX_PENDING_PER_CHANNEL: +/// else if queues[scope].len() >= MAX_PENDING_PER_SCOPE: /// drop oldest (pop_front), warn, push_back new event /// else: -/// queues[event.channel_id].push_back(event) +/// queues[event.scope].push_back(event) /// /// flush_next() β†’ Option: /// expire any stuck in-flight entries past their deadline -/// candidates = channels where queue non-empty -/// AND NOT in in_flight_channels -/// AND (no retry_after OR retry_after[c] <= now) +/// candidates = scopes where queue non-empty +/// AND NOT in in_flight_scopes +/// AND (no retry_after OR retry_after[s] <= now) /// if candidates empty: return None -/// channel = pick candidate with oldest head event (min received_at) -/// events = drain up to MAX_BATCH_EVENTS from queues[channel] -/// in_flight_channels.insert(channel) -/// in_flight_deadlines.insert(channel, now + in_flight_deadline) -/// return Some(FlushBatch { channel, events }) +/// scope = pick candidate with oldest head event (min received_at) +/// events = drain up to MAX_BATCH_EVENTS from queues[scope] +/// in_flight_scopes.insert(scope) +/// in_flight_deadlines.insert(scope, now + in_flight_deadline) +/// return Some(FlushBatch { scope, events }) /// -/// mark_complete(channel_id): -/// in_flight_channels.remove(channel_id) -/// in_flight_deadlines.remove(channel_id) -/// retry_counts.remove(channel_id) +/// mark_complete(scope): +/// in_flight_scopes.remove(scope) +/// in_flight_deadlines.remove(scope) +/// retry_counts.remove(scope) /// clean up expired retry_after entry if present /// /// requeue(batch): -/// increment retry_counts[channel] -/// if retry_counts[channel] > MAX_RETRIES: dead-letter (log ERROR, return batch to caller) +/// increment retry_counts[scope] +/// if retry_counts[scope] > MAX_RETRIES: dead-letter (log ERROR, return batch to caller) /// else: push_front with original received_at, set exponential backoff retry_after with jitter /// ``` pub struct EventQueue { - queues: HashMap>, - in_flight_channels: HashSet, - /// Per-channel deadline for auto-expiring stuck in-flight entries. - in_flight_deadlines: HashMap, + queues: HashMap>, + in_flight_scopes: HashSet, + /// Per-scope deadline for auto-expiring stuck in-flight entries. + in_flight_deadlines: HashMap, /// Number of events in each in-flight batch (for expiry logging). - in_flight_batch_sizes: HashMap, - retry_after: HashMap, - /// Per-channel retry attempt counter for exponential backoff / dead-lettering. - retry_counts: HashMap, + in_flight_batch_sizes: HashMap, + retry_after: HashMap, + /// Per-scope retry attempt counter for exponential backoff / dead-lettering. + retry_counts: HashMap, dedup_mode: DedupMode, - /// Cancelled batches awaiting redispatch, preserving conversation identity. - /// Multiple roots may wait behind the same channel, but are never merged. - cancelled_batches: HashMap>, + /// Cancelled batches awaiting redispatch. Scope-keyed: batches from + /// different roots live under different keys and are never merged; + /// repeat cancels for the SAME scope merge into one batch (most recent + /// reason wins), so each scope holds at most one entry. + 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 /// no-double-deliver invariant holds without any change to the hot drain /// path. Populated by [`mark_native_steer_pending`]; drained back to the /// queue front by [`release_native_steer`] (preserving original - /// `received_at` fairness, same discipline as `requeue_preserve_timestamps` - /// at line 453). Bulk recovery on in-flight deadline expiry is performed - /// by `flush_next` / `has_flushable_work` (recover, not log-and-drop β€” + /// `received_at` fairness, same discipline as `requeue_preserve_timestamps`). + /// Bulk recovery on in-flight deadline expiry is performed by + /// `flush_next` / `has_flushable_work` (recover, not log-and-drop β€” /// the events were never delivered to the agent). - withheld_native_steer: HashMap>, - /// Duration after which an in-flight channel is auto-expired as orphaned. + withheld_native_steer: HashMap>, + /// Duration after which an in-flight scope is auto-expired as orphaned. /// Must be strictly greater than `max_turn_duration` so a turn running to /// the hard cap returns via `mark_complete` before the backstop fires. in_flight_deadline: Duration, @@ -184,7 +233,7 @@ impl EventQueue { pub fn new(dedup_mode: DedupMode) -> Self { Self { queues: HashMap::new(), - in_flight_channels: HashSet::new(), + in_flight_scopes: HashSet::new(), in_flight_deadlines: HashMap::new(), in_flight_batch_sizes: HashMap::new(), retry_after: HashMap::new(), @@ -204,29 +253,29 @@ impl EventQueue { self } - /// Push an event into the queue for its channel. + /// Push an event into the queue for its conversation scope. /// - /// In [`DedupMode::Drop`], events for any currently in-flight channel are + /// In [`DedupMode::Drop`], events for any currently in-flight scope are /// silently discarded (debug-logged). /// /// Returns `true` if the event was accepted, `false` if dropped. pub fn push(&mut self, event: QueuedEvent) -> bool { - if matches!(self.dedup_mode, DedupMode::Drop) - && self.in_flight_channels.contains(&event.channel_id) - { + let scope = event.scope_key(); + if matches!(self.dedup_mode, DedupMode::Drop) && self.in_flight_scopes.contains(&scope) { tracing::debug!( channel_id = %event.channel_id, - "dropping event for in-flight channel (drop mode)" + root = scope.root_event_id.as_deref().unwrap_or(""), + "dropping event for in-flight scope (drop mode)" ); return false; } - let queue = self.queues.entry(event.channel_id).or_default(); - // Enforce per-channel depth cap: drop oldest to make room. - if queue.len() >= MAX_PENDING_PER_CHANNEL { + let queue = self.queues.entry(scope).or_default(); + // Enforce per-scope depth cap: drop oldest to make room. + if queue.len() >= MAX_PENDING_PER_SCOPE { queue.pop_front(); tracing::warn!( channel_id = %event.channel_id, - limit = MAX_PENDING_PER_CHANNEL, + limit = MAX_PENDING_PER_SCOPE, "queue depth cap reached β€” dropped oldest event" ); } @@ -237,95 +286,46 @@ impl EventQueue { /// Try to flush the next batch. /// /// Returns `None` if all non-in-flight, non-throttled queues are empty. - /// Otherwise picks the channel with the oldest pending event (FIFO fairness - /// across channels), drains ALL events for that channel into a single batch, - /// inserts into `in_flight_channels`, and returns the batch. + /// Otherwise picks the scope with the oldest pending event (FIFO fairness + /// across scopes), drains up to [`MAX_BATCH_EVENTS`] for that scope into a + /// single batch, inserts into `in_flight_scopes`, and returns the batch. + /// Every event in a scope's queue shares the same conversation root by + /// construction, so a batch can never mix roots. pub fn flush_next(&mut self) -> Option { let now = Instant::now(); + self.expire_stuck_in_flight(now); - // Auto-expire any stuck in-flight entries that missed mark_complete. - let expired: Vec = self - .in_flight_deadlines - .iter() - .filter(|(_, deadline)| now >= **deadline) - .map(|(id, _)| *id) - .collect(); - for id in expired { - let lost_events = self.in_flight_batch_sizes.remove(&id).unwrap_or(0); - tracing::error!( - channel_id = %id, - lost_events, - deadline_secs = self.in_flight_deadline.as_secs(), - "BUG: in-flight channel expired without mark_complete β€” \ - auto-releasing; {lost_events} dispatched event(s) orphaned" - ); - self.in_flight_channels.remove(&id); - self.in_flight_deadlines.remove(&id); - // Recover any withheld goose-native steer events for the expired - // channel back to the queue front so normal dispatch delivers - // them. Unlike the in-flight batch above (already delivered to a - // now-hung prompt β€” nothing to recover), these events were never - // delivered to the agent. - self.recover_withheld_for_expired_channel(id); - } - - // Find the channel whose head event has the oldest received_at, - // excluding in-flight channels and throttled channels. - let channel_id = self + // Find the scope whose head event has the oldest received_at, + // excluding in-flight scopes and throttled scopes. + let scope = self .queues .iter() - .filter(|(id, q)| { + .filter(|(key, q)| { !q.is_empty() - && !self.in_flight_channels.contains(id) - && self.retry_after.get(id).is_none_or(|&t| t <= now) + && !self.in_flight_scopes.contains(key) + && self.retry_after.get(key).is_none_or(|&t| t <= now) }) .min_by_key(|(_, q)| q.front().unwrap().received_at) - .map(|(id, _)| *id); + .map(|(key, _)| key.clone()); - // Fallback: if no queued events are ready but a channel has cancelled + // Fallback: if no queued events are ready but a scope has cancelled // events waiting (e.g., explicit !cancel with no new @mention), flush // those as a regular batch (re-dispatch unchanged). - let channel_id = match channel_id { - Some(id) => id, + let scope = match scope { + Some(key) => key, None => { - let cancelled_id = self + let cancelled_key = self .cancelled_batches .keys() - .find(|id| !self.in_flight_channels.contains(id)) - .copied(); - return cancelled_id.map(|id| self.dispatch_cancelled(id, now)); + .find(|key| !self.in_flight_scopes.contains(key)) + .cloned(); + return cancelled_key.map(|key| self.dispatch_cancelled(key, 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) - .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()) - .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. - let queue = self.queues.entry(channel_id).or_default(); - // 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 queue = self.queues.entry(scope.clone()).or_default(); + let drain_count = MAX_BATCH_EVENTS.min(queue.len()); let mut events: Vec = queue .drain(..drain_count) .map(|qe| BatchEvent { @@ -341,87 +341,112 @@ impl EventQueue { events.sort_by_key(|be| be.event.created_at); // Remove the queue entry if now empty. - if self.queues.get(&channel_id).is_some_and(|q| q.is_empty()) { - self.queues.remove(&channel_id); + if self.queues.get(&scope).is_some_and(|q| q.is_empty()) { + self.queues.remove(&scope); } - self.in_flight_channels.insert(channel_id); + self.in_flight_scopes.insert(scope.clone()); self.in_flight_deadlines - .insert(channel_id, now + self.in_flight_deadline); - self.in_flight_batch_sizes.insert(channel_id, events.len()); + .insert(scope.clone(), now + self.in_flight_deadline); + self.in_flight_batch_sizes + .insert(scope.clone(), events.len()); - // Merge only a cancelled batch for the same conversation root. - let (cancelled_events, cancel_reason) = self.pop_cancelled(channel_id).map_or_else( + // Merge any cancelled batch waiting under this same scope. + let (cancelled_events, cancel_reason) = self.pop_cancelled(&scope).map_or_else( || (vec![], None), |batch| (batch.events, Some(batch.reason)), ); Some(FlushBatch { - channel_id, - conversation_root: head_root, + channel_id: scope.channel_id, + conversation_root: scope.root_event_id, events, cancelled_events, cancel_reason, }) } - /// 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); + /// Auto-expire any stuck in-flight entries that missed `mark_complete`. + /// Shared by `flush_next` and `has_flushable_work`. + fn expire_stuck_in_flight(&mut self, now: Instant) { + let expired: Vec = self + .in_flight_deadlines + .iter() + .filter(|(_, deadline)| now >= **deadline) + .map(|(key, _)| key.clone()) + .collect(); + for key in expired { + let lost_events = self.in_flight_batch_sizes.remove(&key).unwrap_or(0); + tracing::error!( + channel_id = %key.channel_id, + root = key.root_event_id.as_deref().unwrap_or(""), + lost_events, + deadline_secs = self.in_flight_deadline.as_secs(), + "BUG: in-flight scope expired without mark_complete β€” \ + auto-releasing; {lost_events} dispatched event(s) orphaned" + ); + self.in_flight_scopes.remove(&key); + self.in_flight_deadlines.remove(&key); + // Recover any withheld goose-native steer events for the expired + // scope back to the queue front so normal dispatch delivers + // them. Unlike the in-flight batch above (already delivered to a + // now-hung prompt β€” nothing to recover), these events were never + // delivered to the agent. + self.recover_withheld_for_expired_scope(&key); } - 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 { + /// Take the cancelled batch for `scope`, if any. + fn pop_cancelled(&mut self, scope: &ConversationSessionKey) -> Option { + self.cancelled_batches.remove(scope) + } + + /// Re-dispatch the oldest cancelled batch for `scope` unchanged, + /// marking the scope in-flight. + fn dispatch_cancelled(&mut self, scope: ConversationSessionKey, now: Instant) -> FlushBatch { let cancelled = self - .pop_cancelled(channel_id) - .expect("cancelled channel must have a batch"); - self.in_flight_channels.insert(channel_id); + .pop_cancelled(&scope) + .expect("cancelled scope must have a batch"); + self.in_flight_scopes.insert(scope.clone()); self.in_flight_deadlines - .insert(channel_id, now + self.in_flight_deadline); + .insert(scope.clone(), now + self.in_flight_deadline); self.in_flight_batch_sizes - .insert(channel_id, cancelled.events.len()); + .insert(scope.clone(), cancelled.events.len()); FlushBatch { - channel_id, - conversation_root: cancelled.conversation_root, + channel_id: scope.channel_id, + conversation_root: scope.root_event_id, events: cancelled.events, cancelled_events: vec![], cancel_reason: Some(cancelled.reason), } } - /// Mark the prompt for `channel_id` as complete. + /// Mark the prompt for `scope` as complete. /// - /// Removes the channel from `in_flight_channels` and `in_flight_deadlines`. + /// Removes the scope from `in_flight_scopes` and `in_flight_deadlines`. /// - /// If the channel was NOT requeued (no active `retry_after` throttle), the - /// retry counter is reset β€” the channel is healthy and the next failure - /// starts fresh. If the channel WAS requeued, `retry_counts` is left intact + /// If the scope was NOT requeued (no active `retry_after` throttle), the + /// retry counter is reset β€” the scope is healthy and the next failure + /// starts fresh. If the scope WAS requeued, `retry_counts` is left intact /// so the backoff sequence continues on the next attempt. /// /// Also cleans up any already-expired `retry_after` entry. - pub fn mark_complete(&mut self, channel_id: Uuid) { - self.in_flight_channels.remove(&channel_id); - self.in_flight_deadlines.remove(&channel_id); - self.in_flight_batch_sizes.remove(&channel_id); + pub fn mark_complete(&mut self, scope: &ConversationSessionKey) { + self.in_flight_scopes.remove(scope); + self.in_flight_deadlines.remove(scope); + self.in_flight_batch_sizes.remove(scope); let now = Instant::now(); - match self.retry_after.get(&channel_id) { - // Active throttle β†’ channel was requeued; keep retry_counts intact. + match self.retry_after.get(scope) { + // Active throttle β†’ scope was requeued; keep retry_counts intact. Some(&deadline) if deadline > now => {} // Expired or absent throttle β†’ successful completion; reset counter // and clean up the stale retry_after entry. Some(_) => { - self.retry_after.remove(&channel_id); - self.retry_counts.remove(&channel_id); + self.retry_after.remove(scope); + self.retry_counts.remove(scope); } None => { - self.retry_counts.remove(&channel_id); + self.retry_counts.remove(scope); } } } @@ -441,13 +466,14 @@ impl EventQueue { /// failure notice can be posted to the channel. Returns `None` when the /// batch was requeued for another attempt. /// - /// Note: does NOT remove from `in_flight_channels` β€” caller must call + /// Note: does NOT remove from `in_flight_scopes` β€” caller must call /// `mark_complete` separately. pub fn requeue(&mut self, batch: FlushBatch) -> Option { + let scope = batch.scope_key(); 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); + let count = self.retry_counts.entry(scope.clone()).or_insert(0); *count += 1; *count }; @@ -455,16 +481,17 @@ impl EventQueue { if attempt > MAX_RETRIES { tracing::error!( channel_id = %channel_id, + root = scope.root_event_id.as_deref().unwrap_or(""), attempt, events = batch.events.len(), "dead-lettering batch after {} retries β€” discarding {} events", MAX_RETRIES, batch.events.len(), ); - self.retry_counts.remove(&channel_id); - // Also clear retry_after so fresh traffic on this channel isn't + self.retry_counts.remove(&scope); + // Also clear retry_after so fresh traffic on this scope isn't // throttled by stale backoff from the discarded poison batch. - self.retry_after.remove(&channel_id); + self.retry_after.remove(&scope); return Some(batch); } @@ -490,7 +517,7 @@ impl EventQueue { "requeueing failed batch with backoff" ); - let queue = self.queues.entry(channel_id).or_default(); + let queue = self.queues.entry(scope.clone()).or_default(); // Push to front in reverse order so original order is preserved. for be in batch.events.into_iter().rev() { queue.push_front(QueuedEvent { @@ -501,18 +528,18 @@ impl EventQueue { conversation_root: conversation_root.clone(), }); } - // Enforce per-channel cap: trim oldest (back) events if requeue pushed + // Enforce per-scope cap: trim oldest (back) events if requeue pushed // the queue over the limit. Without this, repeated requeue+push cycles // can grow the queue unboundedly. - while queue.len() > MAX_PENDING_PER_CHANNEL { + while queue.len() > MAX_PENDING_PER_SCOPE { queue.pop_back(); tracing::warn!( channel_id = %channel_id, - limit = MAX_PENDING_PER_CHANNEL, + limit = MAX_PENDING_PER_SCOPE, "requeue overflow β€” dropped oldest event to enforce cap" ); } - self.retry_after.insert(channel_id, Instant::now() + delay); + self.retry_after.insert(scope, Instant::now() + delay); None } @@ -522,12 +549,13 @@ impl EventQueue { /// retry without penalizing the channel's position in the fairness queue /// and without imposing a retry throttle. /// - /// Does NOT set `retry_after`. Does NOT remove from `in_flight_channels` β€” + /// Does NOT set `retry_after`. Does NOT remove from `in_flight_scopes` β€” /// caller must call `mark_complete` separately. pub fn requeue_preserve_timestamps(&mut self, batch: FlushBatch) { + let scope = batch.scope_key(); let channel_id = batch.channel_id; let conversation_root = batch.conversation_root.clone(); - let queue = self.queues.entry(channel_id).or_default(); + let queue = self.queues.entry(scope).or_default(); // Push to front in reverse order so original order is preserved. for be in batch.events.into_iter().rev() { queue.push_front(QueuedEvent { @@ -538,52 +566,86 @@ impl EventQueue { conversation_root: conversation_root.clone(), }); } - // Enforce per-channel cap: trim newest (back) events if over limit. - while queue.len() > MAX_PENDING_PER_CHANNEL { + // Enforce per-scope cap: trim newest (back) events if over limit. + while queue.len() > MAX_PENDING_PER_SCOPE { queue.pop_back(); tracing::warn!( channel_id = %channel_id, - limit = MAX_PENDING_PER_CHANNEL, + limit = MAX_PENDING_PER_SCOPE, "requeue_preserve overflow β€” dropped newest event to enforce cap" ); } } + /// Restore a flushed batch that could not be dispatched (no claimable + /// agent) to its exact pre-flush state: merged cancelled events return + /// to the cancelled store under their original reason, fresh events + /// return to the queue front with original timestamps. No retry + /// accounting. Caller must still call `mark_complete` to release the + /// in-flight entry. + /// + /// This is the undo of `flush_next` β€” `requeue_preserve_timestamps` + /// alone would silently drop `cancelled_events` (or strip the cancel + /// framing from a re-dispatched cancelled batch). + pub fn restore_unclaimed(&mut self, mut batch: FlushBatch) { + match batch.cancel_reason { + // Re-dispatch of a cancelled batch (`dispatch_cancelled` puts + // the cancelled events in `events`): return it whole to the + // cancelled store. + Some(reason) if batch.cancelled_events.is_empty() => { + self.requeue_as_cancelled(batch, reason); + } + // Merged batch: split back β€” cancelled part to the cancelled + // store, fresh part to the queue. + Some(reason) => { + let cancelled = std::mem::take(&mut batch.cancelled_events); + self.requeue_as_cancelled( + FlushBatch { + channel_id: batch.channel_id, + conversation_root: batch.conversation_root.clone(), + events: cancelled, + cancelled_events: vec![], + cancel_reason: Some(reason), + }, + reason, + ); + self.requeue_preserve_timestamps(batch); + } + None => self.requeue_preserve_timestamps(batch), + } + } + /// Requeue a cancelled batch so its events appear as `cancelled_events` - /// in the next `FlushBatch` for this channel (enabling the annotated + /// in the next `FlushBatch` for its scope (enabling the annotated /// merged-prompt format in `format_prompt()`). /// /// `reason` records why the turn was cancelled (steer vs interrupt) so the - /// merged prompt is framed correctly. On a double-cancel, the most recent - /// reason wins. + /// merged prompt is framed correctly. On a double-cancel for the same + /// scope, events accumulate and the most recent reason wins. /// /// Unlike `requeue_preserve_timestamps`, events are NOT pushed back into /// 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 channel_id = batch.channel_id; - let conversation_root = batch.conversation_root; + let scope = batch.scope_key(); 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, - }); + match self.cancelled_batches.entry(scope) { + std::collections::hash_map::Entry::Occupied(mut existing) => { + // Preserve any already-cancelled events for this same scope. + // The most recent cancellation reason wins, matching prior + // double-cancel behavior. + let existing = existing.get_mut(); + existing.events.extend(events); + existing.reason = reason; + } + std::collections::hash_map::Entry::Vacant(slot) => { + slot.insert(CancelledBatch { events, reason }); + } } } - /// Returns `true` if any channel has pending events that are not in-flight + /// Returns `true` if any scope has pending events that are not in-flight /// and not throttled by `retry_after`. /// /// Also auto-expires any stuck in-flight entries whose deadline has passed. @@ -591,112 +653,101 @@ impl EventQueue { /// full `flush_next` call. pub fn has_flushable_work(&mut self) -> bool { let now = Instant::now(); + self.expire_stuck_in_flight(now); - // Auto-expire stuck in-flight entries (same logic as flush_next). - let expired: Vec = self - .in_flight_deadlines - .iter() - .filter(|(_, deadline)| now >= **deadline) - .map(|(id, _)| *id) - .collect(); - for id in expired { - let lost_events = self.in_flight_batch_sizes.remove(&id).unwrap_or(0); - tracing::error!( - channel_id = %id, - lost_events, - deadline_secs = self.in_flight_deadline.as_secs(), - "BUG: in-flight channel expired without mark_complete β€” \ - auto-releasing; {lost_events} dispatched event(s) orphaned" - ); - self.in_flight_channels.remove(&id); - self.in_flight_deadlines.remove(&id); - // Symmetric with the flush_next expiry block: recover withheld - // goose-native steer events for the expired channel so they are - // not permanently orphaned in the side table. - self.recover_withheld_for_expired_channel(id); - } - - self.queues.iter().any(|(id, q)| { + self.queues.iter().any(|(key, q)| { !q.is_empty() - && !self.in_flight_channels.contains(id) - && self.retry_after.get(id).is_none_or(|&t| t <= now) + && !self.in_flight_scopes.contains(key) + && self.retry_after.get(key).is_none_or(|&t| t <= now) }) || self .cancelled_batches .keys() - .any(|id| !self.in_flight_channels.contains(id)) + .any(|key| !self.in_flight_scopes.contains(key)) } - /// Number of channels with pending events. + /// Number of scopes with pending events. pub fn pending_channels(&self) -> usize { self.queues.len() } - /// Number of queued events for a specific channel. Test-only. + /// Number of queued events for a specific scope. Test-only. #[cfg(test)] - pub fn queued_event_count(&self, channel_id: &Uuid) -> usize { - self.queues.get(channel_id).map_or(0, |q| q.len()) + pub fn queued_event_count(&self, scope: &ConversationSessionKey) -> usize { + self.queues.get(scope).map_or(0, |q| q.len()) } - /// Drop all queued (non-in-flight) events for a channel. + /// Drop all queued (non-in-flight) events for a channel, across ALL of + /// its scopes. /// /// Used when the agent is removed from a channel β€” any pending events /// for that channel are stale and should not be prompted. Does NOT /// affect in-flight prompts (those will complete normally; the agent /// may fail to act if it lost access, but that's handled by the relay). /// - /// Also clears any `retry_after` throttle for the channel. + /// Also clears any `retry_after` throttles for the channel's scopes. /// /// Returns the event IDs of dropped events so the caller can clean up /// any reactions (πŸ‘€) that were added at queue-push time. pub fn drain_channel(&mut self, channel_id: Uuid) -> Vec { - let ids = self - .queues - .remove(&channel_id) - .map(|q| q.into_iter().map(|e| e.event.id.to_hex()).collect()) - .unwrap_or_default(); - self.retry_after.remove(&channel_id); - self.retry_counts.remove(&channel_id); - self.cancelled_batches.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 - // will expire (auto-cleaning the channel). Removing deadlines without - // removing in_flight_channels would disable auto-expiry and leave a - // wedged task permanently blocking the channel. + let mut ids = Vec::new(); + self.queues.retain(|key, q| { + if key.channel_id != channel_id { + return true; + } + ids.extend(q.iter().map(|e| e.event.id.to_hex())); + false + }); + self.retry_after + .retain(|key, _| key.channel_id != channel_id); + self.retry_counts + .retain(|key, _| key.channel_id != channel_id); + self.cancelled_batches + .retain(|key, _| key.channel_id != channel_id); + self.withheld_native_steer + .retain(|key, _| key.channel_id != channel_id); + // Preserve in_flight_scopes AND in_flight_deadlines: each in-flight + // task will eventually complete (calling mark_complete) or its deadline + // will expire (auto-cleaning the scope). Removing deadlines without + // removing in_flight_scopes would disable auto-expiry and leave a + // wedged task permanently blocking the scope. ids } - /// Whether a prompt is currently in-flight for the given channel. - pub fn is_channel_in_flight(&self, channel_id: Uuid) -> bool { - self.in_flight_channels.contains(&channel_id) + /// Whether a prompt is currently in-flight for the given scope. + pub fn is_scope_in_flight(&self, scope: &ConversationSessionKey) -> bool { + self.in_flight_scopes.contains(scope) } // ── Goose-native steer withhold (side table) ────────────────────────── // // While a goose-native `_goose/unstable/session/steer` write is in flight // for a specific queued event, that event is moved out of `queues` into - // `withheld_native_steer` so `flush_next` / `has_flushable_work` / the - // contiguous drain at line 285 cannot see it β€” closing the race window - // between `mark_complete` (which clears `in_flight_channels`) and the - // ack arriving on the main loop. On `Success` the event is consumed - // (`remove_event`); on `Err` / `PromptCompletedNeutral` it is released - // back to the queue front (`release_native_steer`), preserving its - // original `received_at` for FIFO fairness. - - /// Move a queued event out of `queues[channel_id]` into the side table + // `withheld_native_steer` so `flush_next` / `has_flushable_work` / + // `drain` cannot see it β€” closing the race window between + // `mark_complete` (which clears `in_flight_scopes`) and the ack arriving + // on the main loop. On `Success` the event is consumed (`remove_event`); + // on `Err` / `PromptCompletedNeutral` it is released back to the queue + // front (`release_native_steer`), preserving its original `received_at` + // for FIFO fairness. + + /// Move a queued event out of `queues[scope]` into the side table /// to withhold it from `flush_next` while a goose-native steer is in /// flight. /// /// Returns `true` if the event was found and withheld, `false` if the - /// event id was not present in `queues[channel_id]` (race-safe no-op: + /// event id was not present in `queues[scope]` (race-safe no-op: /// the event may have already been drained, removed, or never queued). /// /// Must be called synchronously from the mode-gate fork immediately /// after `pool.send_steer` returns `Ok(())` and before any watcher task /// is spawned, so the withhold is established before `mark_complete` / /// any subsequent `flush_next` tick can run. - pub fn mark_native_steer_pending(&mut self, channel_id: Uuid, event_id: &str) -> bool { - let Some(q) = self.queues.get_mut(&channel_id) else { + pub fn mark_native_steer_pending( + &mut self, + scope: &ConversationSessionKey, + event_id: &str, + ) -> bool { + let Some(q) = self.queues.get_mut(scope) else { return false; }; let Some(pos) = q.iter().position(|qe| qe.event.id.to_hex() == event_id) else { @@ -706,27 +757,27 @@ impl EventQueue { .remove(pos) .expect("position came from iter so remove must succeed"); if q.is_empty() { - self.queues.remove(&channel_id); + self.queues.remove(scope); } self.withheld_native_steer - .entry(channel_id) + .entry(scope.clone()) .or_default() .push(qe); true } /// Release a single withheld event back to the front of - /// `queues[channel_id]`, preserving its original `received_at`. + /// `queues[scope]`, preserving its original `received_at`. /// /// Called on `SteerAck::Err(_)` and `SteerAck::PromptCompletedNeutral` /// (delivery unknown after prompt completion; restoring queued event /// for normal dispatch). Idempotent: a no-op if the event was already /// removed or never withheld. /// - /// Push-to-front matches the discipline of `requeue_preserve_timestamps` - /// at line 453, preserving fairness across channels. - pub fn release_native_steer(&mut self, channel_id: Uuid, event_id: &str) { - let Some(entries) = self.withheld_native_steer.get_mut(&channel_id) else { + /// Push-to-front matches the discipline of `requeue_preserve_timestamps`, + /// preserving fairness across scopes. + pub fn release_native_steer(&mut self, scope: &ConversationSessionKey, event_id: &str) { + let Some(entries) = self.withheld_native_steer.get_mut(scope) else { return; }; let Some(pos) = entries @@ -737,18 +788,18 @@ impl EventQueue { }; let qe = entries.remove(pos); if entries.is_empty() { - self.withheld_native_steer.remove(&channel_id); + self.withheld_native_steer.remove(scope); } // Push to FRONT so original `received_at` keeps the event at the head - // of the channel's queue. Per-channel cap is enforced below in case + // of the scope's queue. Per-scope cap is enforced below in case // a flood of events arrived during the ack window. - let queue = self.queues.entry(channel_id).or_default(); + let queue = self.queues.entry(scope.clone()).or_default(); queue.push_front(qe); - while queue.len() > MAX_PENDING_PER_CHANNEL { + while queue.len() > MAX_PENDING_PER_SCOPE { queue.pop_back(); tracing::warn!( - channel_id = %channel_id, - limit = MAX_PENDING_PER_CHANNEL, + channel_id = %scope.channel_id, + limit = MAX_PENDING_PER_SCOPE, "release_native_steer overflow β€” dropped newest event to enforce cap" ); } @@ -760,53 +811,54 @@ impl EventQueue { /// Called on `SteerAck::Success` β€” the agent received the steer, so the /// event has been "delivered" via the non-cancelling path and must not /// be redelivered via normal dispatch. Idempotent across both stores. - pub fn remove_event(&mut self, channel_id: Uuid, event_id: &str) { - if let Some(entries) = self.withheld_native_steer.get_mut(&channel_id) { + pub fn remove_event(&mut self, scope: &ConversationSessionKey, event_id: &str) { + if let Some(entries) = self.withheld_native_steer.get_mut(scope) { entries.retain(|qe| qe.event.id.to_hex() != event_id); if entries.is_empty() { - self.withheld_native_steer.remove(&channel_id); + self.withheld_native_steer.remove(scope); } } - if let Some(q) = self.queues.get_mut(&channel_id) { + if let Some(q) = self.queues.get_mut(scope) { q.retain(|qe| qe.event.id.to_hex() != event_id); if q.is_empty() { - self.queues.remove(&channel_id); + self.queues.remove(scope); } } } - /// Bulk-release every withheld event for `channel_id` back to the queue + /// Bulk-release every withheld event for `scope` back to the queue /// front, preserving relative FIFO order. /// - /// Called from the `in_flight_deadline` expiry blocks in - /// `flush_next` and `has_flushable_work` β€” if a steer ack never arrives - /// (read loop hung, watcher never posted), the withheld events would - /// otherwise be permanently orphaned. Recover, do not log-and-drop: the - /// events were never delivered to the agent, so normal dispatch must - /// have a chance to deliver them. + /// Called from the `in_flight_deadline` expiry path in + /// `expire_stuck_in_flight` β€” if a steer ack never arrives (read loop + /// hung, watcher never posted), the withheld events would otherwise be + /// permanently orphaned. Recover, do not log-and-drop: the events were + /// never delivered to the agent, so normal dispatch must have a chance + /// to deliver them. /// /// Iterates the stored entries in reverse so per-entry `push_front` /// composes to original-FIFO order at the queue front (same discipline - /// as `requeue_preserve_timestamps` at line 453). - fn recover_withheld_for_expired_channel(&mut self, channel_id: Uuid) { - let Some(entries) = self.withheld_native_steer.remove(&channel_id) else { + /// as `requeue_preserve_timestamps`). + fn recover_withheld_for_expired_scope(&mut self, scope: &ConversationSessionKey) { + let Some(entries) = self.withheld_native_steer.remove(scope) else { return; }; let n = entries.len(); - let queue = self.queues.entry(channel_id).or_default(); + let queue = self.queues.entry(scope.clone()).or_default(); for qe in entries.into_iter().rev() { queue.push_front(qe); } - while queue.len() > MAX_PENDING_PER_CHANNEL { + while queue.len() > MAX_PENDING_PER_SCOPE { queue.pop_back(); tracing::warn!( - channel_id = %channel_id, - limit = MAX_PENDING_PER_CHANNEL, + channel_id = %scope.channel_id, + limit = MAX_PENDING_PER_SCOPE, "withheld-steer recovery overflow β€” dropped newest event to enforce cap" ); } tracing::warn!( - channel_id = %channel_id, + channel_id = %scope.channel_id, + root = scope.root_event_id.as_deref().unwrap_or(""), recovered = n, "in-flight expiry recovered withheld steer event(s) β€” \ steer ack never arrived; normal dispatch will deliver" @@ -816,12 +868,12 @@ impl EventQueue { /// Compact expired metadata entries to prevent unbounded map growth. /// /// Removes `retry_after` entries whose deadline has already passed, and - /// cleans up orphaned `retry_counts` entries for channels that have no + /// cleans up orphaned `retry_counts` entries for scopes that have no /// queued events, no active throttle, and no in-flight prompt. Without - /// this, channels that completed their retry cycle but never received + /// this, scopes that completed their retry cycle but never received /// fresh traffic would leak a `u32` entry in `retry_counts` indefinitely. /// - /// The in-flight guard is critical: a channel whose throttle expired and + /// The in-flight guard is critical: a scope whose throttle expired and /// whose queue is empty because it was flushed may still have a retry /// attempt in flight. Removing its `retry_counts` would reset the /// backoff sequence if that attempt fails and requeues. @@ -832,13 +884,13 @@ impl EventQueue { pub fn compact_expired_state(&mut self) { let now = Instant::now(); self.retry_after.retain(|_, deadline| *deadline > now); - // Remove retry_counts for channels with no active throttle, no + // Remove retry_counts for scopes with no active throttle, no // queued events, AND no in-flight prompt β€” they completed their // retry cycle and are truly idle. - self.retry_counts.retain(|ch, _| { - self.retry_after.contains_key(ch) - || self.queues.get(ch).is_some_and(|q| !q.is_empty()) - || self.in_flight_channels.contains(ch) + self.retry_counts.retain(|key, _| { + self.retry_after.contains_key(key) + || self.queues.get(key).is_some_and(|q| !q.is_empty()) + || self.in_flight_scopes.contains(key) }); } } @@ -1727,8 +1779,13 @@ mod tests { q.queues.values().map(|q| q.len()).sum() } + /// Channel-scoped key shorthand for tests exercising legacy scoping. + fn scope(ch: Uuid) -> ConversationSessionKey { + ConversationSessionKey::channel(ch) + } + fn any_in_flight(q: &EventQueue) -> bool { - !q.in_flight_channels.is_empty() + !q.in_flight_scopes.is_empty() } #[test] @@ -1742,7 +1799,7 @@ mod tests { } #[test] - fn test_distinct_conversation_roots_are_flushed_in_separate_channel_batches() { + fn test_distinct_conversation_roots_flush_concurrently_in_separate_batches() { let mut q = EventQueue::new(DedupMode::Queue); let ch = Uuid::new_v4(); let mut first = make_queued(ch, "first root"); @@ -1757,41 +1814,142 @@ mod tests { 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); + // Scheduling is scope-keyed: a different root in the same channel + // flushes while the first is still in flight. let second_batch = q .flush_next() - .expect("second root should flush after completion"); + .expect("second root should flush concurrently"); assert_eq!(second_batch.conversation_root.as_deref(), Some("root-b")); assert_eq!(second_batch.events.len(), 1); + assert!(q.is_scope_in_flight(&first_batch.scope_key())); + assert!(q.is_scope_in_flight(&second_batch.scope_key())); + + // Each root stays serialized within itself: nothing left to flush. + assert!(q.flush_next().is_none()); } #[test] - fn test_cancelled_root_is_redispatched_before_different_queued_root() { + fn test_cancelled_root_redispatches_alongside_other_queued_roots() { 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"); + let scope_a = batch_a.scope_key(); q.requeue_as_cancelled(batch_a, CancelReason::Steer); - q.mark_complete(ch); + q.mark_complete(&scope_a); 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"); + // Fresh root B dispatches without waiting on A's cancelled work… + let dispatched_b = q.flush_next().expect("root B should dispatch"); + 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()); + + // …and A's cancelled batch redispatches concurrently under its own + // scope, with B still in flight. + let redispatched_a = q.flush_next().expect("cancelled A should redispatch"); 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); + assert!(q.is_scope_in_flight(&redispatched_a.scope_key())); + assert!(q.is_scope_in_flight(&dispatched_b.scope_key())); + } - 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()); + /// Helper: a root-scoped queued event. + fn make_queued_rooted(ch: Uuid, root: &str, content: &str) -> QueuedEvent { + let mut qe = make_queued(ch, content); + qe.conversation_root = Some(root.into()); + qe + } + + #[test] + fn test_restore_unclaimed_fresh_batch_is_exact_flush_undo() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + q.push(make_queued_rooted(ch, "root-a", "one")); + q.push(make_queued_rooted(ch, "root-a", "two")); + + let batch = q.flush_next().expect("flush"); + let scope_a = batch.scope_key(); + let original_received: Vec<_> = batch.events.iter().map(|e| e.received_at).collect(); + + q.restore_unclaimed(batch); + q.mark_complete(&scope_a); + + // Exact undo: same events, same order, same timestamps, no retry + // accounting, nothing left in the cancelled store. + let refetched = q.flush_next().expect("restored batch reflushes"); + assert_eq!(refetched.conversation_root.as_deref(), Some("root-a")); + assert_eq!(refetched.events.len(), 2); + assert_eq!(refetched.events[0].event.content, "one"); + assert_eq!(refetched.events[1].event.content, "two"); + let restored_received: Vec<_> = refetched.events.iter().map(|e| e.received_at).collect(); + assert_eq!(restored_received, original_received); + assert!(refetched.cancelled_events.is_empty()); + assert!(q.retry_counts.is_empty()); + assert!(q.cancelled_batches.is_empty()); + } + + #[test] + fn test_restore_unclaimed_merged_batch_preserves_cancel_framing() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + q.push(make_queued_rooted(ch, "root-a", "old")); + let batch = q.flush_next().expect("flush old"); + let scope_a = batch.scope_key(); + q.push(make_queued_rooted(ch, "root-a", "new")); + q.requeue_as_cancelled(batch, CancelReason::Steer); + q.mark_complete(&scope_a); + + // Merged flush: events=[new], cancelled_events=[old]. + let merged = q.flush_next().expect("merged flush"); + assert_eq!(merged.events.len(), 1); + assert_eq!(merged.cancelled_events.len(), 1); + assert_eq!(merged.cancel_reason, Some(CancelReason::Steer)); + + // No agent available β€” restore. The cancelled portion must go back + // to the cancelled store (framing intact), the fresh event back to + // the queue. + q.restore_unclaimed(merged); + q.mark_complete(&scope_a); + + let remerged = q.flush_next().expect("re-merged flush"); + assert_eq!(remerged.events.len(), 1); + assert_eq!(remerged.events[0].event.content, "new"); + assert_eq!(remerged.cancelled_events.len(), 1); + assert_eq!(remerged.cancelled_events[0].event.content, "old"); + assert_eq!(remerged.cancel_reason, Some(CancelReason::Steer)); + } + + #[test] + fn test_restore_unclaimed_cancelled_redispatch_returns_to_cancelled_store() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + q.push(make_queued_rooted(ch, "root-a", "cancelled work")); + let batch = q.flush_next().expect("flush"); + let scope_a = batch.scope_key(); + q.requeue_as_cancelled(batch, CancelReason::Interrupt); + q.mark_complete(&scope_a); + + // Cancelled-only redispatch (dispatch_cancelled path). + let redispatch = q.flush_next().expect("cancelled redispatch"); + assert_eq!(redispatch.cancel_reason, Some(CancelReason::Interrupt)); + assert!(redispatch.cancelled_events.is_empty()); + + // Restore: must return whole to the cancelled store under the + // ORIGINAL reason, not become a plain queued event. + q.restore_unclaimed(redispatch); + q.mark_complete(&scope_a); + assert!(q.cancelled_batches.contains_key(&scope_a)); + + let again = q.flush_next().expect("redispatch again"); + assert_eq!(again.cancel_reason, Some(CancelReason::Interrupt)); + assert_eq!(again.events[0].event.content, "cancelled work"); } #[test] @@ -1853,7 +2011,7 @@ mod tests { assert!(q.flush_next().is_none()); // Complete the in-flight prompt. - q.mark_complete(ch); + q.mark_complete(&scope(ch)); assert!(!any_in_flight(&q)); // Now flush should succeed. @@ -1949,7 +2107,7 @@ mod tests { assert_eq!(pending_count(&q), 1); assert_eq!(q.queues.len(), 1); - q.mark_complete(ch_a); + q.mark_complete(&scope(ch_a)); // Second flush picks B. let batch_b = q.flush_next().expect("second flush"); @@ -2102,7 +2260,7 @@ mod tests { // The mode gate fires Steer β†’ cancel β†’ requeue as cancelled, carrying // the steer reason (exactly the lib.rs requeue path). q.requeue_as_cancelled(batch, CancelReason::Steer); - q.mark_complete(ch); + q.mark_complete(&scope(ch)); // The re-prompt the agent actually receives. let merged = q.flush_next().unwrap(); @@ -2246,10 +2404,10 @@ mod tests { // Simulate failure β€” requeue the batch. queue.requeue(batch); - queue.mark_complete(ch); + queue.mark_complete(&scope(ch)); // retry_after is set, so manually clear it for this test. - queue.retry_after.remove(&ch); + queue.retry_after.remove(&scope(ch)); // Should be able to flush again and get the same events in order. let batch2 = queue.flush_next().unwrap(); @@ -2274,7 +2432,7 @@ mod tests { // Requeue ch_a (simulating failure) and complete. queue.requeue(batch_a); - queue.mark_complete(ch_a); + queue.mark_complete(&scope(ch_a)); // After requeue, ch_a has retry_after set (5s), so ch_b goes first. let next_batch = queue.flush_next().unwrap(); @@ -2635,7 +2793,7 @@ mod tests { q.push(make_queued(ch, "dropped")); assert_eq!(pending_count(&q), 0, "event should be dropped"); - q.mark_complete(ch); + q.mark_complete(&scope(ch)); // Nothing to flush. assert!(q.flush_next().is_none()); } @@ -2654,7 +2812,7 @@ mod tests { q.push(make_queued(ch_b, "B-event")); assert_eq!(pending_count(&q), 1); - q.mark_complete(ch_a); + q.mark_complete(&scope(ch_a)); let batch_b = q.flush_next().expect("flush B"); assert_eq!(batch_b.channel_id, ch_b); } @@ -2678,14 +2836,14 @@ mod tests { assert_eq!(batch_b.channel_id, ch_b); // Both in-flight. - assert_eq!(q.in_flight_channels.len(), 2); + assert_eq!(q.in_flight_scopes.len(), 2); // Complete A only. - q.mark_complete(ch_a); + q.mark_complete(&scope(ch_a)); assert!(any_in_flight(&q)); // B still in-flight. // Complete B. - q.mark_complete(ch_b); + q.mark_complete(&scope(ch_b)); assert!(!any_in_flight(&q)); } @@ -2729,8 +2887,8 @@ mod tests { q.push(make_queued(ch_b, "B-dropped")); assert_eq!(pending_count(&q), 0); - q.mark_complete(ch_a); - q.mark_complete(ch_b); + q.mark_complete(&scope(ch_a)); + q.mark_complete(&scope(ch_b)); } #[test] @@ -2760,9 +2918,9 @@ mod tests { // All in-flight. assert!(q.flush_next().is_none()); - q.mark_complete(ch_a); - q.mark_complete(ch_b); - q.mark_complete(ch_c); + q.mark_complete(&scope(ch_a)); + q.mark_complete(&scope(ch_b)); + q.mark_complete(&scope(ch_c)); } #[test] @@ -2777,18 +2935,18 @@ mod tests { let _batch_a = q.flush_next().expect("flush A"); let _batch_b = q.flush_next().expect("flush B"); - assert_eq!(q.in_flight_channels.len(), 2); + assert_eq!(q.in_flight_scopes.len(), 2); // Complete only A. - q.mark_complete(ch_a); - assert_eq!(q.in_flight_channels.len(), 1); - assert!(q.in_flight_channels.contains(&ch_b)); - assert!(!q.in_flight_channels.contains(&ch_a)); + q.mark_complete(&scope(ch_a)); + assert_eq!(q.in_flight_scopes.len(), 1); + assert!(q.in_flight_scopes.contains(&scope(ch_b))); + assert!(!q.in_flight_scopes.contains(&scope(ch_a))); // B still in-flight. assert!(any_in_flight(&q)); - q.mark_complete(ch_b); + q.mark_complete(&scope(ch_b)); assert!(!any_in_flight(&q)); } @@ -2811,7 +2969,7 @@ mod tests { // requeue_preserve_timestamps should keep the original timestamp. q.requeue_preserve_timestamps(batch); - q.mark_complete(ch); + q.mark_complete(&scope(ch)); // No retry_after set β€” should be immediately flushable. let batch2 = q.flush_next().expect("flush after requeue_preserve"); @@ -2827,10 +2985,10 @@ mod tests { let batch = q.flush_next().expect("flush"); q.requeue_preserve_timestamps(batch); - q.mark_complete(ch); + q.mark_complete(&scope(ch)); // No retry_after β€” channel should be immediately flushable. - assert!(!q.retry_after.contains_key(&ch)); + assert!(!q.retry_after.contains_key(&scope(ch))); assert!(q.flush_next().is_some()); } @@ -2839,34 +2997,34 @@ mod tests { let mut q = EventQueue::new(DedupMode::Queue); let ch = Uuid::new_v4(); - // Fill the channel to MAX_PENDING_PER_CHANNEL. - for i in 0..MAX_PENDING_PER_CHANNEL { + // Fill the channel to MAX_PENDING_PER_SCOPE. + for i in 0..MAX_PENDING_PER_SCOPE { q.push(make_queued(ch, &format!("fill-{i}"))); } - assert_eq!(pending_count(&q), MAX_PENDING_PER_CHANNEL); + assert_eq!(pending_count(&q), MAX_PENDING_PER_SCOPE); // Flush a batch (removes some events from the queue). let batch = q.flush_next().expect("should flush"); let batch_size = batch.events.len(); - let remaining = MAX_PENDING_PER_CHANNEL - batch_size; + let remaining = MAX_PENDING_PER_SCOPE - batch_size; assert_eq!(pending_count(&q), remaining); // Push more events while the batch is "in-flight" β€” fill back to cap. for i in 0..batch_size { q.push(make_queued(ch, &format!("new-{i}"))); } - assert_eq!(pending_count(&q), MAX_PENDING_PER_CHANNEL); + assert_eq!(pending_count(&q), MAX_PENDING_PER_SCOPE); // Requeue the original batch β€” without cap enforcement this would - // push the queue to MAX_PENDING_PER_CHANNEL + batch_size. + // push the queue to MAX_PENDING_PER_SCOPE + batch_size. q.requeue_preserve_timestamps(batch); - // Cap must be enforced: queue should not exceed MAX_PENDING_PER_CHANNEL. + // Cap must be enforced: queue should not exceed MAX_PENDING_PER_SCOPE. assert!( - pending_count(&q) <= MAX_PENDING_PER_CHANNEL, + pending_count(&q) <= MAX_PENDING_PER_SCOPE, "queue exceeded cap: {} > {}", pending_count(&q), - MAX_PENDING_PER_CHANNEL, + MAX_PENDING_PER_SCOPE, ); } @@ -2875,8 +3033,8 @@ mod tests { let mut q = EventQueue::new(DedupMode::Queue); let ch = Uuid::new_v4(); - // Push exactly MAX_PENDING_PER_CHANNEL events with identifiable content. - for i in 0..MAX_PENDING_PER_CHANNEL { + // Push exactly MAX_PENDING_PER_SCOPE events with identifiable content. + for i in 0..MAX_PENDING_PER_SCOPE { q.push(make_queued(ch, &format!("original-{i}"))); } @@ -2894,7 +3052,7 @@ mod tests { // Requeue β€” older events go to front, overflow trims from back (newest). q.requeue_preserve_timestamps(batch); - q.mark_complete(ch); + q.mark_complete(&scope(ch)); // The requeued events should be at the front of the queue. let batch2 = q.flush_next().expect("should flush after requeue"); @@ -2921,14 +3079,14 @@ mod tests { assert!(!q.has_flushable_work()); // Complete β€” no pending events, no flushable work. - q.mark_complete(ch); + q.mark_complete(&scope(ch)); assert!(!q.has_flushable_work()); // Requeue with retry_after β€” throttled, no flushable work. q.push(make_queued(ch, "msg2")); let batch2 = q.flush_next().expect("flush2"); q.requeue(batch2); - q.mark_complete(ch); + q.mark_complete(&scope(ch)); assert!( !q.has_flushable_work(), "throttled channel should not be flushable" @@ -2936,7 +3094,7 @@ mod tests { // Manually expire the retry_after to simulate time passing. q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(scope(ch), Instant::now() - Duration::from_secs(1)); assert!( q.has_flushable_work(), "expired throttle should be flushable" @@ -2951,26 +3109,26 @@ mod tests { q.push(make_queued(ch, "poison")); for attempt in 1..=MAX_RETRIES { q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(scope(ch), Instant::now() - Duration::from_secs(1)); let batch = q.flush_next().expect("flush"); assert!( q.requeue(batch).is_none(), "attempt {attempt} should requeue, not dead-letter" ); - q.mark_complete(ch); + q.mark_complete(&scope(ch)); } // The MAX_RETRIES+1'th failure dead-letters: batch is returned. q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(scope(ch), Instant::now() - Duration::from_secs(1)); let batch = q.flush_next().expect("flush"); let dead = q.requeue(batch).expect("should dead-letter"); assert_eq!(dead.channel_id, ch); assert_eq!(dead.events.len(), 1); - q.mark_complete(ch); + q.mark_complete(&scope(ch)); // Retry state is cleared so fresh traffic isn't throttled. - assert!(!q.retry_counts.contains_key(&ch)); - assert!(!q.retry_after.contains_key(&ch)); + assert!(!q.retry_counts.contains_key(&scope(ch))); + assert!(!q.retry_after.contains_key(&scope(ch))); } #[test] @@ -2984,7 +3142,7 @@ mod tests { // Requeue sets retry_after. q.requeue(batch); - q.mark_complete(ch); + q.mark_complete(&scope(ch)); // Channel is throttled β€” flush_next should return None (no other channels). assert!(q.flush_next().is_none()); @@ -2996,8 +3154,8 @@ mod tests { // After retry_after expires, ch should be flushable again. q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); - q.mark_complete(ch2); + .insert(scope(ch), Instant::now() - Duration::from_secs(1)); + q.mark_complete(&scope(ch2)); let batch3 = q .flush_next() .expect("ch should be flushable after throttle expires"); @@ -3697,7 +3855,7 @@ mod tests { q.push(make_queued(ch, "msg")); let batch = q.flush_next().unwrap(); q.requeue(batch); // sets retry_after - q.mark_complete(ch); + q.mark_complete(&scope(ch)); // Channel is throttled β€” verify drain clears it. assert!(!q.has_flushable_work()); @@ -3741,26 +3899,26 @@ mod tests { q.push(make_queued(ch, "msg1")); let batch = q.flush_next().unwrap(); q.requeue(batch); - q.mark_complete(ch); - assert!(q.retry_after.contains_key(&ch)); - assert!(q.retry_counts.contains_key(&ch)); + q.mark_complete(&scope(ch)); + assert!(q.retry_after.contains_key(&scope(ch))); + assert!(q.retry_counts.contains_key(&scope(ch))); // The requeued event is back in the queue. Flush it again so the // queue is empty (simulating a successful retry dispatch). // We need to wait for retry_after to expire first. q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(scope(ch), Instant::now() - Duration::from_secs(1)); let _batch2 = q.flush_next().unwrap(); // Now mark_complete with no active throttle β€” clears retry_counts. - q.mark_complete(ch); - assert!(!q.retry_counts.contains_key(&ch)); + q.mark_complete(&scope(ch)); + assert!(!q.retry_counts.contains_key(&scope(ch))); // Re-create the orphan scenario: manually insert stale retry_counts // with no queue, no throttle, and no in-flight. - q.retry_counts.insert(ch, 3); + q.retry_counts.insert(scope(ch), 3); q.compact_expired_state(); assert!( - !q.retry_counts.contains_key(&ch), + !q.retry_counts.contains_key(&scope(ch)), "orphaned retry_counts should be removed" ); } @@ -3774,21 +3932,21 @@ mod tests { q.push(make_queued(ch, "msg1")); let batch = q.flush_next().unwrap(); q.requeue(batch); - q.mark_complete(ch); + q.mark_complete(&scope(ch)); // Expire the throttle so the requeued event can be flushed. q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(scope(ch), Instant::now() - Duration::from_secs(1)); let _batch2 = q.flush_next().unwrap(); // Channel is now in-flight with empty queue and expired throttle. - assert!(q.in_flight_channels.contains(&ch)); - assert!(q.queues.get(&ch).is_none_or(|q| q.is_empty())); + assert!(q.in_flight_scopes.contains(&scope(ch))); + assert!(q.queues.get(&scope(ch)).is_none_or(|q| q.is_empty())); // compact must NOT remove retry_counts β€” the in-flight attempt // may fail and requeue, which needs the existing count. q.compact_expired_state(); assert!( - q.retry_counts.contains_key(&ch), + q.retry_counts.contains_key(&scope(ch)), "retry_counts must survive while channel is in-flight" ); } @@ -3800,11 +3958,11 @@ mod tests { // Manually set up: retry_counts exists, queue is non-empty, no throttle. q.push(make_queued(ch, "msg1")); - q.retry_counts.insert(ch, 2); + q.retry_counts.insert(scope(ch), 2); q.compact_expired_state(); assert!( - q.retry_counts.contains_key(&ch), + q.retry_counts.contains_key(&scope(ch)), "retry_counts should survive when queue is non-empty" ); } @@ -3825,7 +3983,7 @@ mod tests { // Cancel the original batch and release the channel. q.requeue_as_cancelled(batch, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(&scope(ch)); // flush_next should merge: events=[new-1], cancelled_events=[old-1, old-2]. let next = q.flush_next().unwrap(); @@ -3847,27 +4005,27 @@ mod tests { let batch = q.flush_next().unwrap(); q.push(make_queued(ch, "new")); q.requeue_as_cancelled(batch, CancelReason::Steer); - q.mark_complete(ch); + q.mark_complete(&scope(ch)); let merged = q.flush_next().unwrap(); assert_eq!( merged.cancel_reason, Some(CancelReason::Steer), "steer reason should reach the merged batch" ); - q.mark_complete(ch); + q.mark_complete(&scope(ch)); // Fallback path (no new event): reason still rides through. q.push(make_queued(ch, "only")); let batch = q.flush_next().unwrap(); q.requeue_as_cancelled(batch, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(&scope(ch)); let fallback = q.flush_next().unwrap(); assert_eq!( fallback.cancel_reason, Some(CancelReason::Interrupt), "interrupt reason should reach the re-dispatched batch" ); - q.mark_complete(ch); + q.mark_complete(&scope(ch)); // A normal (non-cancel) flush carries no reason. q.push(make_queued(ch, "plain")); @@ -3883,12 +4041,12 @@ mod tests { let batch1 = q.flush_next().unwrap(); q.push(make_queued(ch, "new-1")); q.requeue_as_cancelled(batch1, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(&scope(ch)); let batch2 = q.flush_next().unwrap(); // Second cancel with a different reason β€” the latest reason wins. q.requeue_as_cancelled(batch2, CancelReason::Steer); q.push(make_queued(ch, "new-2")); - q.mark_complete(ch); + q.mark_complete(&scope(ch)); let batch3 = q.flush_next().unwrap(); assert_eq!(batch3.cancel_reason, Some(CancelReason::Steer)); } @@ -3906,7 +4064,7 @@ mod tests { // Cancel the batch (no new events pushed) and release the channel. q.requeue_as_cancelled(batch, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(&scope(ch)); // Fallback path: cancelled events become regular events, cancelled_events is empty. let next = q.flush_next().unwrap(); @@ -3930,7 +4088,7 @@ mod tests { q.push(make_queued(ch, "msg")); let batch = q.flush_next().unwrap(); q.requeue_as_cancelled(batch, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(&scope(ch)); // Channel has only cancelled events β€” should still be considered flushable. assert!( @@ -3948,7 +4106,7 @@ mod tests { q.push(make_queued(ch, "msg")); let batch = q.flush_next().unwrap(); q.requeue_as_cancelled(batch, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(&scope(ch)); // drain_channel should clear cancelled_batches for the channel. q.drain_channel(ch); @@ -3976,7 +4134,7 @@ mod tests { // First cancel: store 2 cancelled events. q.requeue_as_cancelled(batch1, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(&scope(ch)); // Second flush: events=[new-1], cancelled_events=[orig-1, orig-2]. let batch2 = q.flush_next().unwrap(); @@ -3989,7 +4147,7 @@ mod tests { // Push 1 more new event and release channel. q.push(make_queued(ch, "new-2")); - q.mark_complete(ch); + q.mark_complete(&scope(ch)); // Third flush: events=[new-2], cancelled_events=[orig-1, orig-2, new-1]. let batch3 = q.flush_next().unwrap(); @@ -4431,7 +4589,7 @@ mod tests { let event_id = qe.event.id.to_hex(); q.push(qe); - assert!(q.mark_native_steer_pending(ch, &event_id)); + assert!(q.mark_native_steer_pending(&scope(ch), &event_id)); assert!( q.flush_next().is_none(), @@ -4442,7 +4600,10 @@ mod tests { "withheld-only channel must not register as flushable work" ); assert_eq!(pending_count(&q), 0); - assert_eq!(q.withheld_native_steer.get(&ch).map(|v| v.len()), Some(1)); + assert_eq!( + q.withheld_native_steer.get(&scope(ch)).map(|v| v.len()), + Some(1) + ); } /// Earlier events on the same channel must flush normally during the @@ -4468,7 +4629,7 @@ mod tests { q.push(e3); // Steer in flight for e3 β€” withhold it from normal dispatch. - assert!(q.mark_native_steer_pending(ch, &e3_id)); + assert!(q.mark_native_steer_pending(&scope(ch), &e3_id)); // Earlier events flush as a normal batch; e3 is invisible. let batch = q @@ -4480,10 +4641,10 @@ mod tests { assert_eq!(batch.events[1].event.id.to_hex(), e2_id); // Earlier batch completes; channel is no longer in flight. - q.mark_complete(ch); + q.mark_complete(&scope(ch)); // Ack arrives as Err or PromptCompletedNeutral β†’ release e3. - q.release_native_steer(ch, &e3_id); + q.release_native_steer(&scope(ch), &e3_id); let next = q.flush_next().expect("released e3 should now flush"); assert_eq!(next.channel_id, ch); @@ -4510,17 +4671,17 @@ mod tests { // Simulate a prompt in flight for `ch`, then withhold the queued // event for an in-flight goose-native steer. - q.in_flight_channels.insert(ch); - q.in_flight_deadlines.insert(ch, Instant::now()); - q.in_flight_batch_sizes.insert(ch, 1); - assert!(q.mark_native_steer_pending(ch, &event_id)); + q.in_flight_scopes.insert(scope(ch)); + q.in_flight_deadlines.insert(scope(ch), Instant::now()); + q.in_flight_batch_sizes.insert(scope(ch), 1); + assert!(q.mark_native_steer_pending(&scope(ch), &event_id)); // Force the in-flight deadline to be in the past, simulating the // steer ack never arriving and the read loop hanging long enough // for `in_flight_deadline` to elapse. Same expiry-simulation // trick used by `test_retry_throttle_blocks_requeue_channel`. q.in_flight_deadlines - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(scope(ch), Instant::now() - Duration::from_secs(1)); // `has_flushable_work` runs the expiry block first; it must recover // the withheld event so the channel registers as flushable. @@ -4568,24 +4729,27 @@ mod tests { // tests. What matters here is that the bulk-recovery path // (reverse iter + push_front) composes to original FIFO at the // queue front. - assert!(q.mark_native_steer_pending(ch, &e1_id)); - assert!(q.mark_native_steer_pending(ch, &e2_id)); - assert!(q.mark_native_steer_pending(ch, &e3_id)); + assert!(q.mark_native_steer_pending(&scope(ch), &e1_id)); + assert!(q.mark_native_steer_pending(&scope(ch), &e2_id)); + assert!(q.mark_native_steer_pending(&scope(ch), &e3_id)); assert_eq!(pending_count(&q), 0); - assert_eq!(q.withheld_native_steer.get(&ch).map(|v| v.len()), Some(3)); + assert_eq!( + q.withheld_native_steer.get(&scope(ch)).map(|v| v.len()), + Some(3) + ); // Trigger expiry β†’ bulk-release path. - q.in_flight_channels.insert(ch); + q.in_flight_scopes.insert(scope(ch)); q.in_flight_deadlines - .insert(ch, Instant::now() - Duration::from_secs(1)); - q.in_flight_batch_sizes.insert(ch, 3); + .insert(scope(ch), Instant::now() - Duration::from_secs(1)); + q.in_flight_batch_sizes.insert(scope(ch), 3); assert!(q.has_flushable_work()); // After recovery, the queue front-to-back order must match the // original FIFO: e1, e2, e3. let recovered: Vec = q .queues - .get(&ch) + .get(&scope(ch)) .expect("queue restored") .iter() .map(|qe| qe.event.id.to_hex()) From 4564ad0ca08810297843d94bee37fa851e312858 Mon Sep 17 00:00:00 2001 From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Date: Sat, 18 Jul 2026 19:52:59 -0400 Subject: [PATCH 06/11] test(acp): cover thread scope compatibility boundaries 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 | 47 ++++++++- crates/buzz-acp/src/lib.rs | 192 +++++++++++++++++++++++++++++++--- 2 files changed, 221 insertions(+), 18 deletions(-) diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 8b45496e75..4f10537f83 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -727,7 +727,26 @@ pub fn normalize_agent_args(command: &str, agent_args: Vec) -> Vec, + legacy: Option<&str>, +) -> Result, ConfigError> { + if canonical.is_some() { + return Ok(None); + } + let Some(value) = legacy else { + return Ok(None); + }; + match value.trim().to_ascii_lowercase().as_str() { + "true" => Ok(Some("thread".into())), + "false" => Ok(Some("channel".into())), + _ => Err(ConfigError::ConfigFile(format!( + "BUZZ_ACP_TOP_LEVEL_SESSIONS must be true or false, got {value:?}" + ))), + } +} + +pub fn propagate_legacy_env_vars() -> Result<(), ConfigError> { for (legacy, canonical) in [ ("BUZZ_ACP_PRIVATE_KEY", "BUZZ_PRIVATE_KEY"), ("BUZZ_ACP_API_TOKEN", "BUZZ_API_TOKEN"), @@ -738,6 +757,12 @@ pub fn propagate_legacy_env_vars() { } } } + let canonical = std::env::var("BUZZ_ACP_SESSION_SCOPE").ok(); + let legacy = std::env::var("BUZZ_ACP_TOP_LEVEL_SESSIONS").ok(); + if let Some(scope) = legacy_session_scope(canonical.as_deref(), legacy.as_deref())? { + std::env::set_var("BUZZ_ACP_SESSION_SCOPE", scope); + } + Ok(()) } impl Config { @@ -2721,6 +2746,26 @@ channels = "ALL" ); } + #[test] + fn legacy_session_scope_translation_and_precedence() { + assert_eq!( + legacy_session_scope(None, Some("true")).unwrap().as_deref(), + Some("thread") + ); + assert_eq!( + legacy_session_scope(None, Some("false")) + .unwrap() + .as_deref(), + Some("channel") + ); + assert_eq!( + legacy_session_scope(Some("channel"), Some("true")).unwrap(), + None + ); + assert_eq!(legacy_session_scope(None, None).unwrap(), None); + assert!(legacy_session_scope(None, Some("not-a-bool")).is_err()); + } + #[test] fn max_turn_duration_ceiling_cannot_overflow_in_flight_deadline() { // The in-flight deadline is max_turn + 100s buffer (IN_FLIGHT_DEADLINE_BUFFER_SECS). diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 8254eb8af1..7cb282834d 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -826,10 +826,12 @@ fn resolve_control_frame_scope( /// (pre-cancel guard), then set `desired_model` + invalidate. The override /// takes visible effect on the agent's next turn. /// -/// An optional `rootEventId` targets one thread scope exactly. Without it, +/// An optional `rootEventId` targets one live thread scope exactly. Without it, /// a busy channel is only switchable when exactly one scope is in flight; -/// with several, the target is ambiguous and nothing is touched -/// (`status: "ambiguous_target"`). Control never fans out across roots. +/// with several, the live-turn target is ambiguous and nothing is touched +/// (`status: "ambiguous_target"`). Live-turn control never fans out across +/// roots. Once an exact live target accepts the switch, the durable model +/// preference is intentionally propagated to idle roots in the same channel. fn handle_switch_model_control( payload: &serde_json::Value, pool: &mut AgentPool, @@ -1140,7 +1142,7 @@ impl Drop for RespawnGuard { // worker threads (Rust 2024 edition safety requirement). pub fn run() -> Result<()> { - config::propagate_legacy_env_vars(); + config::propagate_legacy_env_vars()?; tokio_main() } @@ -2142,16 +2144,13 @@ async fn tokio_main() -> Result<()> { // backed payload) so the cost is negligible. let event_for_steer = buzz_event.event.clone(); let prompt_tag_for_steer = prompt_tag.clone(); - let 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 { - None - }; - let scope = ConversationSessionKey { - channel_id: buzz_event.channel_id, - root_event_id: conversation_root.clone(), - }; + let scope = conversation_scope_for_event( + &config, + &event_for_steer, + buzz_event.channel_id, + &event_id_hex, + ); + let conversation_root = scope.root_event_id.clone(); let accepted = queue.push(QueuedEvent { channel_id: buzz_event.channel_id, event: buzz_event.event, @@ -2673,6 +2672,27 @@ fn signal_in_flight_task( false } +fn conversation_scope_for_event( + config: &Config, + event: &nostr::Event, + channel_id: uuid::Uuid, + event_id_hex: &str, +) -> ConversationSessionKey { + let root_event_id = if matches!(config.session_scope, config::SessionScope::Thread) { + let tags = queue::parse_thread_tags(event); + Some( + tags.root_event_id + .unwrap_or_else(|| event_id_hex.to_owned()), + ) + } else { + None + }; + ConversationSessionKey { + channel_id, + root_event_id, + } +} + /// Resolve a channel-level control action (no explicit root) to a single /// in-flight scope. /// @@ -4858,6 +4878,14 @@ mod error_outcome_emission_tests { async fn panic_event_retains_task_turn_id() { let mut pool = AgentPool::from_slots(vec![]); let channel_id = Uuid::new_v4(); + let panic_scope = ConversationSessionKey { + channel_id, + root_event_id: Some("root-a".into()), + }; + let sibling_scope = ConversationSessionKey { + channel_id, + root_event_id: Some("root-b".into()), + }; let (started_tx, started_rx) = tokio::sync::oneshot::channel(); let abort_handle = pool.join_set.spawn(async move { let _ = started_tx.send(()); @@ -4868,7 +4896,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - scope: Some(ConversationSessionKey::channel(channel_id)), + scope: Some(panic_scope.clone()), turn_id: "panic-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -4883,7 +4911,10 @@ mod error_outcome_emission_tests { let config = test_config(); let mut heartbeat_in_flight = false; let removed_channels = HashSet::new(); - let mut typing_channels = HashMap::new(); + let mut typing_scopes = HashMap::from([ + (panic_scope.clone(), ThreadTags::default()), + (sibling_scope.clone(), ThreadTags::default()), + ]); let mut crash_history = vec![SlotCircuit { crash_times: Vec::new(), open_until: None, @@ -4900,7 +4931,7 @@ mod error_outcome_emission_tests { join_error, &mut heartbeat_in_flight, &removed_channels, - &mut typing_channels, + &mut typing_scopes, &mut crash_history, &respawn_tx, &mut respawn_tasks, @@ -4917,6 +4948,14 @@ mod error_outcome_emission_tests { Some(channel_id.to_string().as_str()) ); assert_eq!(panic.turn_id.as_deref(), Some("panic-turn-id")); + assert!( + !typing_scopes.contains_key(&panic_scope), + "panic cleanup must remove only the failed root's typing state" + ); + assert!( + typing_scopes.contains_key(&sibling_scope), + "panic cleanup must preserve a sibling root's typing state" + ); } #[tokio::test] @@ -5477,6 +5516,31 @@ mod dispatch_scope_tests { }); } + fn tagged_event(content: &str, tags: impl IntoIterator) -> nostr::Event { + EventBuilder::new(Kind::Custom(9), content) + .tags(tags) + .sign_with_keys(&Keys::generate()) + .unwrap() + } + + fn push_via_config_scope( + queue: &mut EventQueue, + config: &Config, + ch: Uuid, + event: nostr::Event, + ) -> ConversationSessionKey { + let event_id = event.id.to_hex(); + let scope = conversation_scope_for_event(config, &event, ch, &event_id); + queue.push(QueuedEvent { + channel_id: ch, + conversation_root: scope.root_event_id.clone(), + event, + received_at: std::time::Instant::now(), + prompt_tag: "test".into(), + }); + scope + } + fn key(ch: Uuid, root: &str) -> ConversationSessionKey { ConversationSessionKey { channel_id: ch, @@ -5484,6 +5548,60 @@ mod dispatch_scope_tests { } } + #[tokio::test] + async fn configured_channel_mode_serializes_distinct_thread_roots() { + 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(); + for root in ["root-a", "root-b"] { + let event = tagged_event(root, [nostr::Tag::parse(["e", root, "", "root"]).unwrap()]); + assert_eq!( + push_via_config_scope(&mut queue, &config, ch, event), + ConversationSessionKey::channel(ch) + ); + } + + let dispatched = dispatch_pending(&mut pool, &mut queue, &test_ctx()); + 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" + ); + } + + #[test] + fn nested_reply_joins_outermost_root_session_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 direct = tagged_event( + "direct", + [nostr::Tag::parse(["e", "outer-root", "", "reply"]).unwrap()], + ); + let nested = tagged_event( + "nested", + [ + nostr::Tag::parse(["e", "outer-root", "", "root"]).unwrap(), + nostr::Tag::parse(["e", "intermediate-parent", "", "reply"]).unwrap(), + ], + ); + let direct_scope = push_via_config_scope(&mut queue, &config, ch, direct); + let nested_scope = push_via_config_scope(&mut queue, &config, ch, nested); + + assert_eq!(direct_scope, key(ch, "outer-root")); + assert_eq!(nested_scope, direct_scope); + let batch = queue.flush_next().expect("shared outer-root batch"); + assert_eq!(batch.scope_key(), key(ch, "outer-root")); + assert_eq!(batch.events.len(), 2); + assert!(queue.flush_next().is_none()); + } + #[tokio::test] async fn two_roots_in_one_channel_dispatch_concurrently_to_distinct_workers() { let mut pool = @@ -5630,6 +5748,46 @@ mod dispatch_scope_tests { assert_eq!(resolve_channel_control_scope(&pool, other), Ok(None)); } + #[tokio::test] + async fn ambiguous_channel_cancel_handler_is_a_no_op_for_all_live_roots() { + let mut pool = AgentPool::from_slots(vec![]); + let ch = Uuid::new_v4(); + let mut receivers = Vec::new(); + for (agent_index, root) in ["root-a", "root-b"].into_iter().enumerate() { + let (control_tx, control_rx) = tokio::sync::oneshot::channel(); + let task_id = pool.join_set.spawn(std::future::pending()).id(); + pool.task_map_mut().insert( + task_id, + pool::TaskMeta { + agent_index, + scope: Some(key(ch, root)), + turn_id: Uuid::new_v4().to_string(), + recoverable_batch: None, + control_tx: Some(control_tx), + steer_tx: None, + }, + ); + receivers.push(control_rx); + } + + handle_cancel_turn_control( + &serde_json::json!({ + "type": "cancel_turn", + "channelId": ch.to_string(), + }), + &mut pool, + None, + ); + + for mut receiver in receivers { + assert_eq!( + receiver.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty), + "ambiguous channel-only cancel must not signal either root" + ); + } + } + #[tokio::test] async fn control_frame_root_event_id_targets_one_scope_exactly() { let mut pool = AgentPool::from_slots(vec![]); From c096503fada67106ea1a9194edc41540ea9d9d64 Mon Sep 17 00:00:00 2001 From: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Date: Sat, 18 Jul 2026 19:50:42 -0400 Subject: [PATCH 07/11] test(acp): pool affinity / LRU-eviction / respawn probes for thread scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer-authored deterministic coverage for the pool-affinity edges Eva flagged unwritten at eca05aa25 (LRU-eviction-under-load, worker death/respawn). All test-only, in the pool.rs test module β€” no production changes. - quinn_lru_evict_stale_reservation_does_not_beat_real_session_holder: a stale session_owners[key]->slot reservation must not win over a different slot that actually holds the session. Proven non-vacuous: fails (claims stale slot 0 vs correct slot 1) when prune_session_owners is neutered, so prune is load-bearing for affinity correctness (prevents a forked provider session). - quinn_bare_stale_reservation_is_pruned: bare stale reservation is dropped by prune -> clean Pass 2 claim, no phantom session. - quinn_busy_owner_does_not_block_unrelated_root: a busy retained root returns ClaimOutcome::BusyOwner (never migrates, never Exhausted) while an unrelated root claims another idle slot concurrently. - quinn_respawn_clears_stale_reservation: a respawned fresh-state agent at the same index does not inherit the dead session via a stale reservation. cargo test -p buzz-acp: 561 passed. clippy -D warnings clean, fmt clean. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- crates/buzz-acp/src/pool.rs | 189 ++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 7a8cf5e7f8..926185b871 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -6134,4 +6134,193 @@ mod tests { "timestamp must not use +00:00 offset" ); } + + // ── Quinn review probes (PR #1981 @ eca05aa25) ───────────────────────── + // Reviewer-authored, NOT for merge as-is: empirical proofs for the + // pool-affinity / LRU-eviction / respawn edges Eva flagged unwritten. + // Run in a DETACHED scratch worktree at the reviewed SHA. + + async fn quinn_probe_agent(index: usize) -> OwnedAgent { + let acp = AcpClient::spawn( + "bash", + &["-c".to_string(), "sleep 30".to_string()], + &[], + false, + ) + .await + .expect("spawn bash probe agent"); + OwnedAgent { + index, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: None, + model_overridden: false, + agent_name: "probe".into(), + goose_system_prompt_supported: None, + protocol_version: 2, + } + } + + // Q1: after a root's session is LRU-evicted from its owning slot, the stale + // session_owners[key]->owner reservation must NOT win over a DIFFERENT slot + // that legitimately holds the session. Without prune, Pass 1 binds blindly to + // the stale owner and FORKS a new session; with prune, the reservation is + // dropped and the session-holding slot is reused. Two slots make the two + // outcomes observably distinct (non-vacuous: fails if prune is neutered). + #[tokio::test] + async fn quinn_lru_evict_stale_reservation_does_not_beat_real_session_holder() { + let channel_id = Uuid::new_v4(); + let mut pool = AgentPool::from_slots(vec![ + Some(quinn_probe_agent(0).await), + Some(quinn_probe_agent(1).await), + ]); + let key = ConversationSessionKey { + channel_id, + root_event_id: Some("root-k".into()), + }; + + // Reservation says slot 0 owns `key`, but slot 0 does NOT hold the session + // (evicted); slot 1 is the real session holder. + pool.session_owners.insert(key.clone(), 0); + pool.agents[1] + .as_mut() + .unwrap() + .state + .insert_session(key.clone(), "real-sess".into()); + assert!(!pool.agents[0] + .as_ref() + .unwrap() + .state + .contains_session(&key)); + assert!(pool.agents[1] + .as_ref() + .unwrap() + .state + .contains_session(&key)); + + // Correct: prune drops the stale reservation, Pass 1 finds slot 1 by + // contains_session, and we reuse the retained session β€” NOT a fresh fork. + match pool.try_claim(Some(&key)) { + ClaimOutcome::Claimed(a) => { + assert_eq!( + a.index, 1, + "must claim the slot that actually holds the session" + ); + assert!( + a.state.contains_session(&key), + "must reuse the retained session, not fork" + ); + } + _ => panic!("expected Claimed on the session-holding slot"), + } + } + + // Q1b: sanity β€” a bare stale reservation (no other holder) is dropped by prune + // and falls through to a clean Pass 2 claim without phantom session content. + #[tokio::test] + async fn quinn_bare_stale_reservation_is_pruned() { + let channel_id = Uuid::new_v4(); + let mut pool = AgentPool::from_slots(vec![Some(quinn_probe_agent(0).await)]); + let key = ConversationSessionKey { + channel_id, + root_event_id: Some("root-b".into()), + }; + pool.session_owners.insert(key.clone(), 0); // stale: slot 0 holds no such session + match pool.try_claim(Some(&key)) { + ClaimOutcome::Claimed(a) => assert!(!a.state.contains_session(&key)), + _ => panic!("expected clean Claimed after pruning bare stale reservation"), + } + } + + // Q2: strict affinity β€” a root whose owning slot is checked out (busy) + // returns BusyOwner (never migrates to another idle slot, never Exhausted), + // and an UNRELATED root can still claim a different idle slot concurrently. + #[tokio::test] + async fn quinn_busy_owner_does_not_block_unrelated_root() { + let channel_id = Uuid::new_v4(); + let mut pool = AgentPool::from_slots(vec![ + Some(quinn_probe_agent(0).await), + Some(quinn_probe_agent(1).await), + ]); + 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()), + }; + + // Claim A (checks out its slot) and record a task_map entry so prune keeps + // the reservation alive (busy owner), mirroring an in-flight turn. + let a_agent = pool.try_claim(Some(&root_a)).claimed().expect("A claimed"); + let a_idx = a_agent.index; + // prune_session_owners keeps a reservation alive iff a task_map entry has + // agent_index == owner (busy) β€” only agent_index matters here. task_map is + // keyed by tokio::task::Id, so mint a real one from a spawned task. + let real_task_id = tokio::spawn(async {}).id(); + pool.task_map.insert( + real_task_id, + TaskMeta { + agent_index: a_idx, + scope: Some(root_a.clone()), + turn_id: "turn-a".to_string(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + }, + ); + + // Second A turn must report BusyOwner β€” not migrate, not Exhausted. + assert!( + matches!(pool.try_claim(Some(&root_a)), ClaimOutcome::BusyOwner), + "same busy root must yield BusyOwner" + ); + + // Unrelated root B claims the OTHER idle slot concurrently. + let b_agent = pool + .try_claim(Some(&root_b)) + .claimed() + .expect("B claims other slot"); + assert_ne!(b_agent.index, a_idx, "B must not steal A's slot"); + let _ = a_agent; + } + + // Q3: worker death/respawn β€” a respawned agent enters the SAME index with a + // fresh default SessionState. A stale session_owners reservation for the dead + // slot must NOT survive: prune drops it (fresh state has no session, no live + // task), so the root re-claims cleanly instead of binding a phantom session. + #[tokio::test] + async fn quinn_respawn_clears_stale_reservation() { + let channel_id = Uuid::new_v4(); + let mut pool = AgentPool::from_slots(vec![Some(quinn_probe_agent(0).await)]); + let root = ConversationSessionKey { + channel_id, + root_event_id: Some("root-x".into()), + }; + + let mut agent = pool.try_claim(Some(&root)).claimed().expect("claimed"); + agent.state.insert_session(root.clone(), "sess-x".into()); + // Simulate death: drop the agent WITHOUT returning it; slot 0 now empty, + // but session_owners still holds root -> 0. + drop(agent); + assert_eq!( + pool.session_owners.get(&root), + Some(&0), + "reservation outlives the dead slot" + ); + assert!(pool.agents[0].is_none(), "slot empty after death"); + + // Respawn: fresh default-state agent at the same index (mirrors lib.rs:1731). + pool.return_agent(quinn_probe_agent(0).await); + + match pool.try_claim(Some(&root)) { + ClaimOutcome::Claimed(a) => assert!( + !a.state.contains_session(&root), + "respawned slot must not inherit the dead session via stale reservation" + ), + _ => panic!("expected clean Claimed after respawn"), + } + } } From 926648990642baab510c90e796610e08393db3b7 Mon Sep 17 00:00:00 2001 From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Date: Sat, 18 Jul 2026 20:01:06 -0400 Subject: [PATCH 08/11] test(acp): cover root-local lifecycle isolation 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/lib.rs | 38 ++++++++++++++++ crates/buzz-acp/src/pool.rs | 35 +++++++-------- crates/buzz-acp/src/queue.rs | 84 ++++++++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 20 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 7cb282834d..c4eae8e809 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -5684,6 +5684,44 @@ mod dispatch_scope_tests { ); } + #[tokio::test] + async fn one_slot_a_then_b_then_a_reclaims_retained_provider_session() { + let mut pool = AgentPool::from_slots(vec![Some(dummy_agent(0).await)]); + let ch = Uuid::new_v4(); + let a = key(ch, "root-a"); + let b = key(ch, "root-b"); + + let mut first_a = pool + .try_claim(Some(&a)) + .claimed() + .expect("first A dispatch"); + first_a + .state + .insert_session(a.clone(), "provider-session-a".into()); + let a_owner = first_a.index; + pool.return_agent(first_a); + + let mut dispatched_b = pool + .try_claim(Some(&b)) + .claimed() + .expect("B dispatches after A completes"); + assert_eq!(dispatched_b.index, a_owner); + dispatched_b + .state + .insert_session(b.clone(), "provider-session-b".into()); + pool.return_agent(dispatched_b); + + let later_a = pool + .try_claim(Some(&a)) + .claimed() + .expect("later A dispatch"); + assert_eq!(later_a.index, a_owner); + assert!( + later_a.state.contains_session(&a), + "A must reclaim its retained provider session after B used the slot" + ); + } + #[tokio::test] async fn pool_exhaustion_restores_undispatched_batch_losslessly() { let mut pool = AgentPool::from_slots(vec![Some(dummy_agent(0).await)]); diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 926185b871..9f3d8ce91d 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -6135,12 +6135,11 @@ mod tests { ); } - // ── Quinn review probes (PR #1981 @ eca05aa25) ───────────────────────── - // Reviewer-authored, NOT for merge as-is: empirical proofs for the - // pool-affinity / LRU-eviction / respawn edges Eva flagged unwritten. - // Run in a DETACHED scratch worktree at the reviewed SHA. + // ── Pool affinity / LRU-eviction / respawn regressions ───────────────── + // Deterministic coverage for reservation pruning, strict owner affinity, + // unrelated-root progress, and worker replacement. - async fn quinn_probe_agent(index: usize) -> OwnedAgent { + async fn probe_agent(index: usize) -> OwnedAgent { let acp = AcpClient::spawn( "bash", &["-c".to_string(), "sleep 30".to_string()], @@ -6169,12 +6168,10 @@ mod tests { // dropped and the session-holding slot is reused. Two slots make the two // outcomes observably distinct (non-vacuous: fails if prune is neutered). #[tokio::test] - async fn quinn_lru_evict_stale_reservation_does_not_beat_real_session_holder() { + async fn lru_evict_stale_reservation_does_not_beat_real_session_holder() { let channel_id = Uuid::new_v4(); - let mut pool = AgentPool::from_slots(vec![ - Some(quinn_probe_agent(0).await), - Some(quinn_probe_agent(1).await), - ]); + let mut pool = + AgentPool::from_slots(vec![Some(probe_agent(0).await), Some(probe_agent(1).await)]); let key = ConversationSessionKey { channel_id, root_event_id: Some("root-k".into()), @@ -6219,9 +6216,9 @@ mod tests { // Q1b: sanity β€” a bare stale reservation (no other holder) is dropped by prune // and falls through to a clean Pass 2 claim without phantom session content. #[tokio::test] - async fn quinn_bare_stale_reservation_is_pruned() { + async fn bare_stale_reservation_is_pruned() { let channel_id = Uuid::new_v4(); - let mut pool = AgentPool::from_slots(vec![Some(quinn_probe_agent(0).await)]); + let mut pool = AgentPool::from_slots(vec![Some(probe_agent(0).await)]); let key = ConversationSessionKey { channel_id, root_event_id: Some("root-b".into()), @@ -6237,12 +6234,10 @@ mod tests { // returns BusyOwner (never migrates to another idle slot, never Exhausted), // and an UNRELATED root can still claim a different idle slot concurrently. #[tokio::test] - async fn quinn_busy_owner_does_not_block_unrelated_root() { + async fn busy_owner_does_not_block_unrelated_root() { let channel_id = Uuid::new_v4(); - let mut pool = AgentPool::from_slots(vec![ - Some(quinn_probe_agent(0).await), - Some(quinn_probe_agent(1).await), - ]); + let mut pool = + AgentPool::from_slots(vec![Some(probe_agent(0).await), Some(probe_agent(1).await)]); let root_a = ConversationSessionKey { channel_id, root_event_id: Some("root-a".into()), @@ -6292,9 +6287,9 @@ mod tests { // slot must NOT survive: prune drops it (fresh state has no session, no live // task), so the root re-claims cleanly instead of binding a phantom session. #[tokio::test] - async fn quinn_respawn_clears_stale_reservation() { + async fn respawn_clears_stale_reservation() { let channel_id = Uuid::new_v4(); - let mut pool = AgentPool::from_slots(vec![Some(quinn_probe_agent(0).await)]); + let mut pool = AgentPool::from_slots(vec![Some(probe_agent(0).await)]); let root = ConversationSessionKey { channel_id, root_event_id: Some("root-x".into()), @@ -6313,7 +6308,7 @@ mod tests { assert!(pool.agents[0].is_none(), "slot empty after death"); // Respawn: fresh default-state agent at the same index (mirrors lib.rs:1731). - pool.return_agent(quinn_probe_agent(0).await); + pool.return_agent(probe_agent(0).await); match pool.try_claim(Some(&root)) { ClaimOutcome::Claimed(a) => assert!( diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 7f12197ff1..fb1c80ace7 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -1788,6 +1788,90 @@ mod tests { !q.in_flight_scopes.is_empty() } + fn rooted(ch: Uuid, root: &str, content: &str) -> QueuedEvent { + let mut event = make_queued(ch, content); + event.conversation_root = Some(root.into()); + event + } + + fn root_scope(ch: Uuid, root: &str) -> ConversationSessionKey { + ConversationSessionKey { + channel_id: ch, + root_event_id: Some(root.into()), + } + } + + #[test] + fn same_channel_root_retry_metadata_is_isolated() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + let a = root_scope(ch, "root-a"); + let b = root_scope(ch, "root-b"); + q.push(rooted(ch, "root-a", "A")); + q.push(rooted(ch, "root-b", "B")); + let batch_a = q.flush_next().expect("flush A"); + let batch_b = q.flush_next().expect("flush B"); + + q.requeue(batch_b); + let b_count = q.retry_counts[&b]; + let b_retry_after = q.retry_after[&b]; + q.requeue(batch_a); + + assert_eq!(q.retry_counts[&a], 1); + assert_eq!(q.retry_counts[&b], b_count); + assert_eq!(q.retry_after[&b], b_retry_after); + } + + #[test] + fn same_channel_root_deadline_expiry_preserves_sibling_state() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + let a = root_scope(ch, "root-a"); + let b = root_scope(ch, "root-b"); + q.push(rooted(ch, "root-a", "A")); + q.push(rooted(ch, "root-b", "B")); + q.flush_next().expect("flush A"); + q.flush_next().expect("flush B"); + let b_deadline = Instant::now() + Duration::from_secs(60); + let b_retry_after = Instant::now() + Duration::from_secs(30); + q.in_flight_deadlines.insert(a.clone(), Instant::now()); + q.in_flight_deadlines.insert(b.clone(), b_deadline); + q.retry_counts.insert(b.clone(), 2); + q.retry_after.insert(b.clone(), b_retry_after); + + q.expire_stuck_in_flight(Instant::now() + Duration::from_millis(1)); + + assert!(!q.in_flight_scopes.contains(&a)); + assert!(q.in_flight_scopes.contains(&b)); + assert_eq!(q.in_flight_deadlines[&b], b_deadline); + assert_eq!(q.retry_counts[&b], 2); + assert_eq!(q.retry_after[&b], b_retry_after); + } + + #[test] + fn same_channel_mark_complete_preserves_sibling_lifecycle_state() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + let a = root_scope(ch, "root-a"); + let b = root_scope(ch, "root-b"); + q.push(rooted(ch, "root-a", "A")); + q.push(rooted(ch, "root-b", "B")); + q.flush_next().expect("flush A"); + q.flush_next().expect("flush B"); + let b_deadline = q.in_flight_deadlines[&b]; + let b_retry_after = Instant::now() + Duration::from_secs(30); + q.retry_counts.insert(b.clone(), 3); + q.retry_after.insert(b.clone(), b_retry_after); + + q.mark_complete(&a); + + assert!(!q.in_flight_scopes.contains(&a)); + assert!(q.in_flight_scopes.contains(&b)); + assert_eq!(q.in_flight_deadlines[&b], b_deadline); + assert_eq!(q.retry_counts[&b], 3); + assert_eq!(q.retry_after[&b], b_retry_after); + } + #[test] fn test_base_section_prepends_header_and_trims_trailing_whitespace() { // Trailing whitespace/newlines are stripped; the [Base] header is From 7722639f8e3883c138d5fd4a82ccd119adefb560 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Sat, 18 Jul 2026 19:58:42 -0400 Subject: [PATCH 09/11] fix(desktop): durable session-scope migration and rollback convergence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings from the PR #1981 session-scope setting: Legacy materialization (Mari, MAJOR 1): hydration read the legacy acp_top_level_sessions override but never persisted the translation, so removing the compatibility reader would silently flip explicit channel users to the thread default. hydrate_scope now atomically materializes an explicit legacy value into acp_session_scope during hydration β€” falseβ†’channel, trueβ†’thread β€” while the bare no-field default stays unwritten (unset remains distinguishable from an override) and an explicit new-field value is never rewritten. DesktopSettings gains a #[serde(flatten)] passthrough so any save preserves JSON fields owned by other features. A failed materialization write keeps the translated scope in memory and the legacy field on disk for retry β€” no divergence. Rollback convergence (Mari, MAJOR 2): on apply failure with a failed rollback write, the old path restarted processes and forced the UI to 'previous' while the authoritative backend still held the new value β€” false convergence. applyAcpSessionScopeSetting now establishes the authoritative scope first (confirmed rollback write, else re-read via getBackend), reconciles processes and UI to that actual value, and if the authority is unreadable fires onUnrecoverable β€” the card surfaces a hard recovery state and disables the toggle instead of claiming a scope. Tests: 7 new Tauri hydration/materialization tests (incl. read-only-dir write-failure injection) and 2 new rollback-matrix tests (authority re-read reconciliation; double-failure hard recovery). Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- desktop/src-tauri/src/commands/experiments.rs | 144 +++++++++++++++++- .../ui/AcpSessionScopeSettingsCard.tsx | 14 +- .../ui/acpSessionScopeSetting.test.mjs | 82 ++++++++++ .../settings/ui/acpSessionScopeSetting.ts | 32 +++- 4 files changed, 263 insertions(+), 9 deletions(-) diff --git a/desktop/src-tauri/src/commands/experiments.rs b/desktop/src-tauri/src/commands/experiments.rs index e7c971d3fa..5094087a4e 100644 --- a/desktop/src-tauri/src/commands/experiments.rs +++ b/desktop/src-tauri/src/commands/experiments.rs @@ -43,6 +43,9 @@ struct DesktopSettings { // have had a release cycle to persist `acp_session_scope`. #[serde(skip_serializing)] acp_top_level_sessions: Option, + // Fields owned by other features must survive our writes untouched. + #[serde(flatten)] + extra: serde_json::Map, } impl DesktopSettings { @@ -97,10 +100,8 @@ fn save_settings(path: &Path, settings: &DesktopSettings) -> Result<(), String> pub(crate) fn acp_session_scope(app: &AppHandle) -> AcpSessionScope { HYDRATE.call_once( - || match settings_path(app).and_then(|path| load_settings(&path)) { - Ok(settings) => { - ACP_SESSION_SCOPE.store(settings.session_scope().atomic_value(), Ordering::Release) - } + || match settings_path(app).and_then(|path| hydrate_scope(&path)) { + Ok(scope) => ACP_SESSION_SCOPE.store(scope.atomic_value(), Ordering::Release), Err(error) => { eprintln!("buzz-desktop: failed to hydrate desktop settings: {error}"); } @@ -112,6 +113,29 @@ pub(crate) fn acp_session_scope(app: &AppHandle) -> AcpSessionScope { } } +/// Load the persisted scope, materializing an explicit legacy override into +/// the durable `acp_session_scope` field so the compatibility reader can be +/// removed without flipping untouched legacy installs to the default. +/// +/// The ordinary no-field default is deliberately NOT written: unset must stay +/// distinguishable from a user/legacy override. A failed materialization +/// write keeps the translated scope in memory and leaves the legacy field on +/// disk, so the next launch retries β€” no divergence, no corruption. +fn hydrate_scope(path: &Path) -> Result { + let mut settings = load_settings(path)?; + let scope = settings.session_scope(); + if settings.acp_session_scope.is_none() && settings.acp_top_level_sessions.is_some() { + settings.acp_session_scope = Some(scope); + if let Err(error) = save_settings(path, &settings) { + eprintln!( + "buzz-desktop: failed to materialize legacy ACP session scope \ + (kept in memory; legacy field retained on disk for retry): {error}" + ); + } + } + Ok(scope) +} + #[tauri::command] pub fn get_acp_session_scope(app: AppHandle) -> AcpSessionScope { acp_session_scope(&app) @@ -130,7 +154,7 @@ pub fn set_acp_session_scope(scope: AcpSessionScope, app: AppHandle) -> Result<( #[cfg(test)] mod tests { - use super::{load_settings, save_settings, AcpSessionScope, DesktopSettings}; + use super::{hydrate_scope, load_settings, save_settings, AcpSessionScope, DesktopSettings}; #[test] fn missing_store_defaults_to_thread_scope() { @@ -148,6 +172,7 @@ mod tests { &DesktopSettings { acp_session_scope: Some(AcpSessionScope::Channel), acp_top_level_sessions: None, + extra: Default::default(), }, ) .unwrap(); @@ -175,4 +200,113 @@ mod tests { std::fs::write(&path, b"not json").unwrap(); assert!(load_settings(&path).is_err()); } + + #[test] + fn hydration_materializes_legacy_opt_out_as_channel() { + 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!(hydrate_scope(&path).unwrap(), AcpSessionScope::Channel); + let persisted: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(persisted["acp_session_scope"], "channel"); + assert!(persisted.get("acp_top_level_sessions").is_none()); + // Restart: the durable field alone must reproduce the override. + assert_eq!(hydrate_scope(&path).unwrap(), AcpSessionScope::Channel); + } + + #[test] + fn hydration_materializes_legacy_opt_in_as_thread() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("desktop-experiments.json"); + std::fs::write(&path, br#"{"acp_top_level_sessions":true}"#).unwrap(); + assert_eq!(hydrate_scope(&path).unwrap(), AcpSessionScope::Thread); + let persisted: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(persisted["acp_session_scope"], "thread"); + assert!(persisted.get("acp_top_level_sessions").is_none()); + } + + #[test] + fn hydration_never_materializes_the_bare_default() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("desktop-experiments.json"); + std::fs::write(&path, br#"{"other_feature":1}"#).unwrap(); + let before = std::fs::read(&path).unwrap(); + assert_eq!(hydrate_scope(&path).unwrap(), AcpSessionScope::Thread); + // Unset must remain distinguishable from an override: no write. + assert_eq!(std::fs::read(&path).unwrap(), before); + // Nor is a missing store created. + let missing = dir.path().join("missing.json"); + assert_eq!(hydrate_scope(&missing).unwrap(), AcpSessionScope::Thread); + assert!(!missing.exists()); + } + + #[test] + fn explicit_new_field_wins_over_legacy_and_is_not_rewritten() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("desktop-experiments.json"); + std::fs::write( + &path, + br#"{"acp_session_scope":"channel","acp_top_level_sessions":true}"#, + ) + .unwrap(); + let before = std::fs::read(&path).unwrap(); + assert_eq!(hydrate_scope(&path).unwrap(), AcpSessionScope::Channel); + assert_eq!(std::fs::read(&path).unwrap(), before); + } + + #[test] + fn materialization_preserves_unrelated_fields() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("desktop-experiments.json"); + std::fs::write( + &path, + br#"{"acp_top_level_sessions":false,"other_feature":{"nested":true},"count":3}"#, + ) + .unwrap(); + assert_eq!(hydrate_scope(&path).unwrap(), AcpSessionScope::Channel); + let persisted: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(persisted["acp_session_scope"], "channel"); + assert_eq!(persisted["other_feature"]["nested"], true); + assert_eq!(persisted["count"], 3); + } + + #[test] + fn save_preserves_unrelated_fields_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("desktop-experiments.json"); + std::fs::write( + &path, + br#"{"acp_session_scope":"thread","other_feature":"keep-me"}"#, + ) + .unwrap(); + let mut settings = load_settings(&path).unwrap(); + settings.acp_session_scope = Some(AcpSessionScope::Channel); + save_settings(&path, &settings).unwrap(); + let persisted: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(persisted["acp_session_scope"], "channel"); + assert_eq!(persisted["other_feature"], "keep-me"); + } + + #[cfg(unix)] + #[test] + fn failed_materialization_keeps_translated_scope_and_legacy_field() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("desktop-experiments.json"); + let legacy = br#"{"acp_top_level_sessions":false}"#; + std::fs::write(&path, legacy).unwrap(); + // Read-only directory: the atomic temp-file write must fail. + let readonly = std::fs::Permissions::from_mode(0o555); + std::fs::set_permissions(dir.path(), readonly).unwrap(); + // In memory: translated override. On disk: legacy field untouched, + // so the next launch retries the materialization. No divergence. + assert_eq!(hydrate_scope(&path).unwrap(), AcpSessionScope::Channel); + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), legacy); + assert_eq!(hydrate_scope(&path).unwrap(), AcpSessionScope::Channel); + } } diff --git a/desktop/src/features/settings/ui/AcpSessionScopeSettingsCard.tsx b/desktop/src/features/settings/ui/AcpSessionScopeSettingsCard.tsx index 3579533a26..1ffbe9199c 100644 --- a/desktop/src/features/settings/ui/AcpSessionScopeSettingsCard.tsx +++ b/desktop/src/features/settings/ui/AcpSessionScopeSettingsCard.tsx @@ -13,6 +13,7 @@ type SessionScope = "thread" | "channel"; export function AcpSessionScopeSettingsCard() { const [scope, setScope] = useState("thread"); const [pending, setPending] = useState(true); + const [unrecoverable, setUnrecoverable] = useState(false); useEffect(() => { let cancelled = false; @@ -37,10 +38,12 @@ export function AcpSessionScopeSettingsCard() { await applyAcpSessionScopeSetting(scope === "thread", threadScoped, { setBackend: (next) => invokeTauri("set_acp_session_scope", { scope: next }), + getBackend: () => invokeTauri("get_acp_session_scope"), listAgents: listManagedAgents, stopAgent: stopManagedAgent, startAgent: startManagedAgent, setUi: (enabled) => setScope(enabled ? "thread" : "channel"), + onUnrecoverable: () => setUnrecoverable(true), }); } catch (error) { console.error("Failed to apply ACP session scope", error); @@ -64,12 +67,21 @@ export function AcpSessionScopeSettingsCard() { Run separate threads concurrently. Turn this off for one legacy session per channel.

+ {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();