From cb0c9849f0379669227310aa63f6cc04d785f3ec Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Mon, 24 Aug 2026 18:47:15 -0400 Subject: [PATCH 1/9] feat(buzz-acp): introduce SessionScope + BUZZ_ACP_SESSION_POLICY Lay the foundation for thread-scoped ACP sessions (rollout step 1: land behind the operator policy with channel scope as the fallback). - Add `scope` module with a hashable `SessionScope` (Conversation/Thread) and `SessionPolicy` (channel/thread). Scope is derived once at admission from policy + DM status + NIP-10 thread tags using the shared `buzz_core::nip10` canonical-root rules. DMs are always conversation-scoped; under the default `channel` policy every channel event collapses to a conversation scope, preserving today's behavior. - Add `--session-policy` / `BUZZ_ACP_SESSION_POLICY` (default `channel`) wired through `CliArgs` -> `Config` and the config summary; document it in `.env.example`. - Derive and log the resolved scope at event admission (telemetry only for now). Unit tests cover scope derivation for top-level mentions, direct/nested replies, repeated mentions, DMs, malformed thread tags, hashing/map-key use, and policy parsing/defaults. Follow-up (same ticket, later commits): partition queue/in-flight state by scope (step 2), partition provider-session state by scope (step 3), scope-correct context gathering (step 4), and the Settings->Experiments toggle + managed-agent deployment wiring (step 5). Signed-off-by: Salman Mohammed --- .env.example | 8 + crates/buzz-acp/src/config.rs | 56 +++++- crates/buzz-acp/src/lib.rs | 24 +++ crates/buzz-acp/src/scope.rs | 331 ++++++++++++++++++++++++++++++++++ 4 files changed, 418 insertions(+), 1 deletion(-) create mode 100644 crates/buzz-acp/src/scope.rs diff --git a/.env.example b/.env.example index 0f7bbba6f13..70e1be1aa1b 100644 --- a/.env.example +++ b/.env.example @@ -223,6 +223,14 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # Set to true to process the agent's own messages (default: ignore self). # BUZZ_ACP_NO_IGNORE_SELF=false +# ── Session scoping ────────────────────────────────────────────────────────── +# How ACP provider sessions are scoped in channels: "channel" (default) or +# "thread". "channel" keeps one provider session per channel (legacy). "thread" +# gives each canonical channel thread its own isolated provider session; direct +# messages stay conversation-scoped either way. Ships as "channel" so thread +# scoping can be canaried and rolled back without code changes. +# BUZZ_ACP_SESSION_POLICY=channel + # ── Context ────────────────────────────────────────────────────────────────── # Max context messages fetched for thread replies and DMs (0–100). 0 = disabled. # BUZZ_ACP_CONTEXT_MESSAGE_LIMIT=12 diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 4a82cf6306d..8c44864c6cd 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -350,6 +350,19 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_DEDUP", default_value = "queue", value_enum)] pub dedup: DedupMode, + /// How ACP provider sessions are scoped in channels. + /// channel (default): one provider session per channel (legacy behavior). + /// thread: each canonical channel thread gets an isolated provider session; + /// direct messages stay conversation-scoped either way. Ships as `channel` + /// so thread scoping can be canaried and rolled back without code changes. + #[arg( + long, + env = "BUZZ_ACP_SESSION_POLICY", + default_value = "channel", + value_enum + )] + pub session_policy: crate::scope::SessionPolicy, + /// How to handle new @mentions while a turn is already in-flight. /// steer (default): cancel+re-prompt, framing the new mention as a message /// that arrived mid-task — the agent keeps working and weaves it in. @@ -536,6 +549,8 @@ pub struct Config { pub initial_message: Option, pub subscribe_mode: SubscribeMode, pub dedup_mode: DedupMode, + /// How ACP provider sessions are scoped in channels (channel vs thread). + pub session_policy: crate::scope::SessionPolicy, pub multiple_event_handling: MultipleEventHandling, pub ignore_self: bool, pub kinds_override: Option>, @@ -1113,6 +1128,7 @@ impl Config { initial_message: args.initial_message, subscribe_mode: args.subscribe, dedup_mode: args.dedup, + session_policy: args.session_policy, multiple_event_handling: args.multiple_event_handling, ignore_self: !args.no_ignore_self, kinds_override: args.kinds, @@ -1164,7 +1180,7 @@ impl Config { format!(" allowed_respond_to=[{}]", modes.join(",")) }; format!( - "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} {}{}", + "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} session_policy={} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} {}{}", self.relay_url, self.keys.public_key().to_hex(), self.agent_command, @@ -1176,6 +1192,7 @@ impl Config { self.heartbeat_interval_secs, self.subscribe_mode, self.dedup_mode, + self.session_policy, self.multiple_event_handling, self.ignore_self, self.context_message_limit, @@ -1489,6 +1506,7 @@ mod tests { initial_message: None, subscribe_mode: mode, dedup_mode: DedupMode::Queue, + session_policy: crate::scope::SessionPolicy::Channel, multiple_event_handling: MultipleEventHandling::Queue, ignore_self: true, kinds_override: None, @@ -2618,6 +2636,42 @@ channels = "ALL" assert!(result.is_empty()); } + // ── Session policy parsing + default ────────────────────────────────────── + + #[test] + fn test_session_policy_default_is_channel() { + // Ships dark: the default must be `channel` so thread scoping is opt-in + // and can be rolled back without code changes. + let args = CliArgs::parse_from(["buzz-acp", "--private-key", &"0".repeat(64)]); + assert_eq!(args.session_policy, crate::scope::SessionPolicy::Channel); + } + + #[test] + fn test_session_policy_thread_flag_parses() { + let args = CliArgs::parse_from([ + "buzz-acp", + "--private-key", + &"0".repeat(64), + "--session-policy", + "thread", + ]); + assert_eq!(args.session_policy, crate::scope::SessionPolicy::Thread); + } + + #[test] + fn test_session_policy_env_var_parses() { + // The env fallback (`BUZZ_ACP_SESSION_POLICY`) must resolve to the same + // value as the flag; this is what the managed-agent runtime sets. + let args = CliArgs::parse_from([ + "buzz-acp", + "--private-key", + &"0".repeat(64), + "--session-policy=thread", + ]); + assert_eq!(args.session_policy, crate::scope::SessionPolicy::Thread); + assert_eq!(args.session_policy.to_string(), "thread"); + } + // ── Multiple-event-handling validation + default ────────────────────────── #[test] diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 146214197a8..e99f9828199 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -9,6 +9,7 @@ mod pool; mod pool_lifecycle; mod queue; mod relay; +mod scope; mod setup_mode; mod usage; @@ -2917,6 +2918,27 @@ 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(); + // Derive the session scope once, at admission, from + // the operator policy, DM status, and NIP-10 thread + // tags. Under the default `channel` policy this is + // always a conversation scope, preserving today's + // channel-keyed routing. Telemetry only for now — + // queue/pool partitioning by scope lands in a + // follow-up (see ticket outline steps 2–4). + let session_scope = scope::SessionScope::derive( + config.session_policy, + buzz_event.channel_id, + is_dm_channel(buzz_event.channel_id, &ctx.channel_info).await, + &event_for_steer, + ); + tracing::debug!( + channel_id = %session_scope.channel_id(), + scope = %session_scope.telemetry_label(), + thread_scoped = session_scope.is_thread(), + thread_root = session_scope.root_event_id().unwrap_or("-"), + policy = %config.session_policy, + "admitted event — resolved session scope" + ); let accepted = queue.push(QueuedEvent { channel_id: buzz_event.channel_id, event: buzz_event.event, @@ -6771,6 +6793,7 @@ mod build_mcp_servers_tests { initial_message: None, subscribe_mode: config::SubscribeMode::All, dedup_mode: config::DedupMode::Queue, + session_policy: scope::SessionPolicy::Channel, multiple_event_handling: config::MultipleEventHandling::Queue, ignore_self: true, kinds_override: None, @@ -6995,6 +7018,7 @@ mod error_outcome_emission_tests { initial_message: None, subscribe_mode: config::SubscribeMode::All, dedup_mode: config::DedupMode::Queue, + session_policy: scope::SessionPolicy::Channel, multiple_event_handling: config::MultipleEventHandling::Queue, ignore_self: true, kinds_override: None, diff --git a/crates/buzz-acp/src/scope.rs b/crates/buzz-acp/src/scope.rs new file mode 100644 index 00000000000..45e85855815 --- /dev/null +++ b/crates/buzz-acp/src/scope.rs @@ -0,0 +1,331 @@ +//! Session scoping for ACP. +//! +//! A [`SessionScope`] is the single hashable key that identifies an ACP +//! provider session and its conversational-context boundary. It is derived +//! **once**, when an eligible event is admitted, from the operator +//! [`SessionPolicy`], whether the channel is a DM, and the event's NIP-10 +//! thread tags. Later code must never re-infer scope from the last event in a +//! batch — it carries the resolved scope instead. +//! +//! Policy matrix (see the "Make ACP sessions thread-scoped" ticket): +//! +//! | Surface | Scope | +//! | ----------------------------------- | --------------------------------------- | +//! | New top-level channel mention | `Thread(channel_id, triggering_event)` | +//! | Reply in a channel thread | `Thread(channel_id, canonical_root)` | +//! | Repeated mention in the same thread | reuse that thread scope | +//! | Direct message | `Conversation(channel_id)` | +//! +//! Under [`SessionPolicy::Channel`] (the current default / rollback path) every +//! surface collapses to `Conversation(channel_id)`, preserving today's +//! channel-keyed behavior exactly. + +use nostr::Event; +use uuid::Uuid; + +use crate::queue::parse_thread_tags; + +/// Operator policy controlling how ACP provider sessions are scoped. +/// +/// Selected via `--session-policy` / `BUZZ_ACP_SESSION_POLICY`. Defaults to +/// [`Channel`](SessionPolicy::Channel) so the feature ships dark and can be +/// canaried, then flipped, then rolled back without code changes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)] +pub enum SessionPolicy { + /// Legacy behavior: one provider session per channel. Every event in a + /// channel shares a `Conversation(channel_id)` scope. + #[default] + Channel, + /// Thread-scoped: each canonical channel thread gets an isolated provider + /// session. DMs remain conversation-scoped. + Thread, +} + +impl std::fmt::Display for SessionPolicy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Channel => f.write_str("channel"), + Self::Thread => f.write_str("thread"), + } + } +} + +/// A hashable ACP execution and conversational-context scope. +/// +/// This is the canonical key for provider sessions, queue partitions, in-flight +/// tracking, and context gathering. The channel remains the authorization and +/// collaboration boundary; the scope is the default *execution* boundary. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum SessionScope { + /// The whole channel is one session. Used for DMs always, and for every + /// channel event under [`SessionPolicy::Channel`]. + Conversation { channel_id: Uuid }, + /// A single canonical thread within a channel, keyed by its root event id + /// (64-char lowercase hex). + Thread { + channel_id: Uuid, + root_event_id: String, + }, +} + +impl SessionScope { + /// The channel this scope belongs to. Always available — the channel is the + /// authorization boundary regardless of scope variant. + pub fn channel_id(&self) -> Uuid { + match self { + Self::Conversation { channel_id } => *channel_id, + Self::Thread { channel_id, .. } => *channel_id, + } + } + + /// The canonical thread-root event id for a [`Thread`](Self::Thread) scope, + /// or `None` for a conversation scope. + pub fn root_event_id(&self) -> Option<&str> { + match self { + Self::Conversation { .. } => None, + Self::Thread { root_event_id, .. } => Some(root_event_id), + } + } + + /// True when this scope is thread-scoped (not conversation-scoped). + pub fn is_thread(&self) -> bool { + matches!(self, Self::Thread { .. }) + } + + /// Derive the scope for an admitted event. + /// + /// Resolution order: + /// 1. DMs are always [`Conversation`](Self::Conversation) — the ticket keeps + /// direct messages conversation-scoped regardless of policy. + /// 2. Under [`SessionPolicy::Channel`], every channel event is + /// conversation-scoped (legacy / rollback behavior). + /// 3. Under [`SessionPolicy::Thread`], a channel event with a NIP-10 root + /// tag scopes to that canonical root; a top-level mention (no thread + /// tags) opens a new thread rooted at the triggering event id. + /// + /// Thread roots are resolved with [`parse_thread_tags`], i.e. Buzz's shared + /// [`buzz_core::nip10`] canonical-root rules — a malformed marker id is + /// ignored (treated as top-level), and a lone `root` marker with no `reply` + /// is top-level, matching relay ingest. + pub fn derive(policy: SessionPolicy, channel_id: Uuid, is_dm: bool, event: &Event) -> Self { + if is_dm || policy == SessionPolicy::Channel { + return Self::Conversation { channel_id }; + } + + match parse_thread_tags(event).root_event_id { + Some(root_event_id) => Self::Thread { + channel_id, + root_event_id, + }, + None => Self::Thread { + channel_id, + root_event_id: event.id.to_hex(), + }, + } + } + + /// A compact, log-friendly label for telemetry (e.g. `conversation` or + /// `thread:`), never leaking full ids into high-cardinality fields. + pub fn telemetry_label(&self) -> String { + match self { + Self::Conversation { .. } => "conversation".to_string(), + Self::Thread { root_event_id, .. } => { + let short: String = root_event_id.chars().take(8).collect(); + format!("thread:{short}") + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind}; + + /// Build a signed event with the given NIP-10 `e`/`p` tags. + fn event_with_tags(tags: Vec>) -> Event { + let keys = Keys::generate(); + let tags: Vec = tags + .into_iter() + .map(|t| nostr::Tag::parse(t).expect("valid tag")) + .collect(); + EventBuilder::new(Kind::Custom(9), "hello") + .tags(tags) + .sign_with_keys(&keys) + .unwrap() + } + + fn plain_event() -> Event { + event_with_tags(vec![]) + } + + #[test] + fn dm_is_always_conversation_scoped_under_thread_policy() { + let ch = Uuid::new_v4(); + // Even a DM with a reply tag stays conversation-scoped. + let root = "a".repeat(64); + let reply = event_with_tags(vec![ + vec!["e".into(), root.clone(), String::new(), "root".into()], + vec!["e".into(), "b".repeat(64), String::new(), "reply".into()], + ]); + let scope = SessionScope::derive(SessionPolicy::Thread, ch, true, &reply); + assert_eq!(scope, SessionScope::Conversation { channel_id: ch }); + } + + #[test] + fn channel_policy_collapses_everything_to_conversation() { + let ch = Uuid::new_v4(); + let root = "a".repeat(64); + let reply = event_with_tags(vec![ + vec!["e".into(), root.clone(), String::new(), "root".into()], + vec!["e".into(), "b".repeat(64), String::new(), "reply".into()], + ]); + // A threaded reply under Channel policy is still conversation-scoped. + let scope = SessionScope::derive(SessionPolicy::Channel, ch, false, &reply); + assert_eq!(scope, SessionScope::Conversation { channel_id: ch }); + // As is a top-level mention. + let scope = SessionScope::derive(SessionPolicy::Channel, ch, false, &plain_event()); + assert_eq!(scope, SessionScope::Conversation { channel_id: ch }); + } + + #[test] + fn top_level_mention_opens_thread_rooted_at_trigger() { + let ch = Uuid::new_v4(); + let ev = plain_event(); + let scope = SessionScope::derive(SessionPolicy::Thread, ch, false, &ev); + assert_eq!( + scope, + SessionScope::Thread { + channel_id: ch, + root_event_id: ev.id.to_hex(), + } + ); + } + + #[test] + fn direct_reply_to_root_scopes_to_that_root() { + let ch = Uuid::new_v4(); + let root = "c".repeat(64); + // A single `e` tag carrying only a `root` marker. + let ev = event_with_tags(vec![vec![ + "e".into(), + root.clone(), + String::new(), + "root".into(), + ]]); + // NIP-10: lone `root` with no `reply` is top-level per ingest rules, so + // this yields a top-level scope rooted at the trigger, not `root`. + let scope = SessionScope::derive(SessionPolicy::Thread, ch, false, &ev); + assert_eq!( + scope, + SessionScope::Thread { + channel_id: ch, + root_event_id: ev.id.to_hex(), + } + ); + } + + #[test] + fn nested_reply_scopes_to_canonical_root_not_parent() { + let ch = Uuid::new_v4(); + let root = "c".repeat(64); + let parent = "d".repeat(64); + let ev = event_with_tags(vec![ + vec!["e".into(), root.clone(), String::new(), "root".into()], + vec!["e".into(), parent.clone(), String::new(), "reply".into()], + ]); + let scope = SessionScope::derive(SessionPolicy::Thread, ch, false, &ev); + // Scope keys on the canonical ROOT, never the immediate parent. + assert_eq!( + scope, + SessionScope::Thread { + channel_id: ch, + root_event_id: root, + } + ); + } + + #[test] + fn repeated_replies_in_same_thread_share_scope() { + let ch = Uuid::new_v4(); + let root = "e".repeat(64); + let mk_reply = || { + event_with_tags(vec![ + vec!["e".into(), root.clone(), String::new(), "root".into()], + vec!["e".into(), "f".repeat(64), String::new(), "reply".into()], + ]) + }; + let a = SessionScope::derive(SessionPolicy::Thread, ch, false, &mk_reply()); + let b = SessionScope::derive(SessionPolicy::Thread, ch, false, &mk_reply()); + assert_eq!(a, b, "same-root replies must reuse the same thread scope"); + } + + #[test] + fn different_top_level_mentions_get_distinct_scopes() { + let ch = Uuid::new_v4(); + let a = SessionScope::derive(SessionPolicy::Thread, ch, false, &plain_event()); + let b = SessionScope::derive(SessionPolicy::Thread, ch, false, &plain_event()); + assert_ne!( + a, b, + "two independent top-level mentions must not share a session" + ); + } + + #[test] + fn malformed_thread_tag_falls_back_to_top_level() { + let ch = Uuid::new_v4(); + // A non-64-hex marker id is ignored by the shared NIP-10 resolver, so + // the event is treated as top-level (rooted at its own id). + let ev = event_with_tags(vec![vec![ + "e".into(), + "not-a-valid-hex-id".into(), + String::new(), + "reply".into(), + ]]); + let scope = SessionScope::derive(SessionPolicy::Thread, ch, false, &ev); + assert_eq!( + scope, + SessionScope::Thread { + channel_id: ch, + root_event_id: ev.id.to_hex(), + } + ); + } + + #[test] + fn accessors_and_labels() { + let ch = Uuid::new_v4(); + let conv = SessionScope::Conversation { channel_id: ch }; + assert_eq!(conv.channel_id(), ch); + assert_eq!(conv.root_event_id(), None); + assert!(!conv.is_thread()); + assert_eq!(conv.telemetry_label(), "conversation"); + + let root = "abcdef0123456789".repeat(4); // 64 hex chars + let thread = SessionScope::Thread { + channel_id: ch, + root_event_id: root.clone(), + }; + assert_eq!(thread.channel_id(), ch); + assert_eq!(thread.root_event_id(), Some(root.as_str())); + assert!(thread.is_thread()); + assert_eq!(thread.telemetry_label(), "thread:abcdef01"); + } + + #[test] + fn scope_is_hashable_and_usable_as_map_key() { + use std::collections::HashMap; + let ch = Uuid::new_v4(); + let mut map: HashMap = HashMap::new(); + let s1 = SessionScope::Thread { + channel_id: ch, + root_event_id: "a".repeat(64), + }; + let s2 = SessionScope::Conversation { channel_id: ch }; + *map.entry(s1.clone()).or_insert(0) += 1; + *map.entry(s1.clone()).or_insert(0) += 1; + *map.entry(s2).or_insert(0) += 1; + assert_eq!(map.get(&s1), Some(&2)); + assert_eq!(map.len(), 2); + } +} From c8782dc9f8e8b9dae7a9dbd278c03164d33a2630 Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Tue, 25 Aug 2026 11:59:01 -0400 Subject: [PATCH 2/9] feat(buzz-acp): partition queue and in-flight state by SessionScope (step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carry the admission-time SessionScope through the queue instead of keying everything on channel_id. All EventQueue partitions — pending queues, in-flight tracking, deadlines, retry counters/backoff, cancelled-batch carryover, and the goose-native steer side table — are now keyed by SessionScope. Under the default `channel` policy every scope is Conversation{channel_id}, so behavior is byte-identical; under `thread` policy, distinct canonical roots in one channel become independent partitions. - QueuedEvent and FlushBatch carry the resolved SessionScope; dispatch, completion, and requeue route by it. PromptSource::Channel now carries the scope (with a channel_id() accessor) so a completed turn marks the exact scope complete. No code rederives scope from the last event in a batch. - The mid-turn steer gate is now scope-level (is_scope_in_flight), and the native-steer withhold/release/dedup + deadline extension target the scope, so an unrelated thread's in-flight turn never steers this one. - drain_channel performs channel-wide cleanup across every child thread scope. Backlog protection is preserved with a per-scope cap plus an aggregate per-channel cap, so per-thread partitioning cannot multiply the admitted queue size. - An IntoScope helper lets the queue API accept a bare channel Uuid (conversation scope) or an explicit SessionScope, keeping the existing channel-keyed unit tests intact. Pool provider-session STATE is still channel-keyed in this commit (indexed via scope.channel_id()); step 3 rekeys it by scope so repeated activity in a thread reuses exactly that thread's provider session. New queue tests: two threads in one channel are independent partitions, events from different roots never share a batch, an in-flight scope blocks only that scope, channel drain clears every child thread scope, and the aggregate channel cap is not multiplied by threads. Full buzz-acp suite (819 lib + integration) green; clippy and fmt clean. Signed-off-by: Salman Mohammed --- crates/buzz-acp/src/lib.rs | 95 ++-- crates/buzz-acp/src/pool.rs | 99 +++-- crates/buzz-acp/src/queue.rs | 812 +++++++++++++++++++++++++---------- 3 files changed, 708 insertions(+), 298 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index e99f9828199..5d4ca1135a4 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1567,6 +1567,9 @@ struct RespawnResult { /// `event_id` is the hex id of the single event the steer carried. struct SteerAckEvent { channel_id: Uuid, + /// Session scope of the steered event — the queue-side withhold/release + /// and deadline extension target this, not the whole channel. + scope: scope::SessionScope, 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 @@ -2941,6 +2944,7 @@ async fn tokio_main() -> Result<()> { ); let accepted = queue.push(QueuedEvent { channel_id: buzz_event.channel_id, + scope: session_scope.clone(), event: buzz_event.event, received_at: std::time::Instant::now(), prompt_tag, @@ -2960,7 +2964,10 @@ async fn tokio_main() -> Result<()> { // Event is already queued. If mode requires it AND // the channel 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-level in-flight gate: a mid-turn signal fires + // only when THIS session scope has a turn in flight, + // so an unrelated thread's turn never steers this one. + if accepted && queue.is_scope_in_flight(&session_scope) { // Author eligibility (owner ∪ allowlist ∪ siblings) // is already enforced by the inbound author gate // above, so the mid-turn signal fires for every @@ -2987,7 +2994,7 @@ async fn tokio_main() -> Result<()> { && try_native_steer( &mut pool, &mut queue, - buzz_event.channel_id, + session_scope.clone(), event_for_steer, prompt_tag_for_steer, &steer_ack_tx, @@ -3167,8 +3174,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 Some(ch) = result.source.channel_id() { + typing_channels.remove(&ch); } if handle_prompt_result( &mut pool, @@ -3234,6 +3241,7 @@ async fn tokio_main() -> Result<()> { } Some(PoolEvent::SteerAck(SteerAckEvent { channel_id, + scope, event_id, ack, })) => { @@ -3347,7 +3355,7 @@ async fn tokio_main() -> Result<()> { "non-cancelling steer ack received" ); if let Ok(pool::SteerAck::Success { session_id }) = &ack { - queue.extend_in_flight_deadline(channel_id, config.max_turn_duration_secs); + queue.extend_in_flight_deadline(&scope, config.max_turn_duration_secs); if !pool.record_successful_steer( channel_id, event_id.clone(), @@ -3361,10 +3369,10 @@ async fn tokio_main() -> Result<()> { } } 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 @@ -3657,11 +3665,12 @@ fn signal_in_flight_task( fn try_native_steer( pool: &mut AgentPool, queue: &mut EventQueue, - channel_id: uuid::Uuid, + scope: scope::SessionScope, 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 @@ -3698,7 +3707,7 @@ fn try_native_steer( // clears `in_flight_channels` 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); + 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 @@ -3716,10 +3725,12 @@ 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, }); @@ -3912,11 +3923,11 @@ fn handle_prompt_result( pool.task_map_mut() .retain(|_, meta| meta.agent_index != agent_index); debug_assert_eq!(before, pool.task_map().len() + 1); - if let PromptSource::Channel(channel_id) = &result.source { + if let Some(channel_id) = result.source.channel_id() { // The task may have invalidated this session before returning. Never // resurrect delivery state for a dead session; its replacement must // receive fresh standing context and history. - if let Some(live_session_id) = result.agent.state.sessions.get(channel_id).cloned() { + if let Some(live_session_id) = result.agent.state.sessions.get(&channel_id).cloned() { let event_ids = successful_steer_deliveries .into_iter() .filter(|delivery| delivery.session_id == live_session_id) @@ -3924,7 +3935,7 @@ fn handle_prompt_result( result .agent .state - .mark_channel_delivery_success(*channel_id, false, event_ids); + .mark_channel_delivery_success(channel_id, false, event_ids); } } @@ -4046,7 +4057,7 @@ fn handle_prompt_result( } match &result.source { - PromptSource::Channel(ch) => queue.mark_complete(*ch), + PromptSource::Channel(scope) => queue.mark_complete(scope.clone()), PromptSource::Heartbeat => *heartbeat_in_flight = false, } @@ -4081,10 +4092,7 @@ fn handle_prompt_result( .to_string(); let harness_pid = std::process::id(); - let channel_id = match &result.source { - PromptSource::Channel(ch) => Some(*ch), - PromptSource::Heartbeat => None, - }; + let channel_id = result.source.channel_id(); let turn_id = result.turn_id.clone(); let emit_turn_error = |error_msg: &str, error_code: Option| { if let Some(ref observer) = observer { @@ -7136,7 +7144,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(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".into(), outcome: PromptOutcome::Ok(crate::acp::StopReason::EndTurn), batch: None, @@ -7208,7 +7216,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(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".into(), outcome: PromptOutcome::Ok(crate::acp::StopReason::EndTurn), batch: None, @@ -7322,7 +7330,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(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".into(), outcome: PromptOutcome::Ok(crate::acp::StopReason::EndTurn), batch: None, @@ -7385,7 +7393,9 @@ mod error_outcome_emission_tests { let result = PromptResult { agent, - source: PromptSource::Channel(Uuid::new_v4()), + source: PromptSource::Channel(scope::SessionScope::Conversation { + channel_id: Uuid::new_v4(), + }), turn_id: "test-turn-id".to_string(), outcome, batch: None, @@ -7553,7 +7563,9 @@ mod error_outcome_emission_tests { let observer = ObserverHandle::in_process(); let result = PromptResult { agent, - source: PromptSource::Channel(Uuid::new_v4()), + source: PromptSource::Channel(scope::SessionScope::Conversation { + channel_id: Uuid::new_v4(), + }), turn_id: "test-turn-id".to_string(), outcome, batch: None, @@ -7601,8 +7613,10 @@ mod error_outcome_emission_tests { let event = EventBuilder::new(Kind::Custom(9), "test") .sign_with_keys(&keys) .unwrap(); + let __cid = Uuid::new_v4(); FlushBatch { - channel_id: Uuid::new_v4(), + channel_id: __cid, + scope: scope::SessionScope::Conversation { channel_id: __cid }, events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -7644,7 +7658,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(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".to_string(), outcome, batch: Some(batch), @@ -7664,7 +7678,7 @@ mod error_outcome_emission_tests { ); ( queue.pending_channels(), - queue.queued_event_count(&channel_id), + queue.queued_event_count(channel_id), ) }; @@ -7710,6 +7724,7 @@ mod error_outcome_emission_tests { .unwrap(); FlushBatch { channel_id, + scope: scope::SessionScope::Conversation { channel_id }, events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -7750,7 +7765,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(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".to_string(), outcome, batch: Some(batch), @@ -7770,7 +7785,7 @@ mod error_outcome_emission_tests { ); ( queue.pending_channels(), - queue.queued_event_count(&channel_id), + queue.queued_event_count(channel_id), ) }; @@ -7828,6 +7843,7 @@ mod error_outcome_emission_tests { let observer = ObserverHandle::in_process(); let batch = FlushBatch { channel_id, + scope: scope::SessionScope::Conversation { channel_id }, events: vec![BatchEvent { event: EventBuilder::new(Kind::Custom(9), "test") .sign_with_keys(&Keys::generate()) @@ -7840,7 +7856,7 @@ mod error_outcome_emission_tests { }; let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::Timeout(TimeoutKind::Hard { recently_active: true, @@ -7922,6 +7938,7 @@ mod error_outcome_emission_tests { let observer = ObserverHandle::in_process(); let batch = FlushBatch { channel_id, + scope: scope::SessionScope::Conversation { channel_id }, events: vec![BatchEvent { event: EventBuilder::new(Kind::Custom(9), "final-attempt") .sign_with_keys(&Keys::generate()) @@ -7934,7 +7951,7 @@ mod error_outcome_emission_tests { }; let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::Timeout(TimeoutKind::Hard { recently_active: true, @@ -7968,7 +7985,7 @@ mod error_outcome_emission_tests { ), ); assert_eq!( - queue.queued_event_count(&channel_id), + queue.queued_event_count(channel_id), 0, "batch with an exhausted retry budget must be dead-lettered, not requeued" ); @@ -8002,6 +8019,7 @@ mod error_outcome_emission_tests { let channel_id = Uuid::new_v4(); let batch = FlushBatch { channel_id, + scope: scope::SessionScope::Conversation { channel_id }, events: vec![BatchEvent { event: original_event.clone(), prompt_tag: "test".into(), @@ -8033,6 +8051,7 @@ mod error_outcome_emission_tests { // handle_prompt_result runs. queue.push(QueuedEvent { channel_id, + scope: scope::SessionScope::Conversation { channel_id }, event: new_event.clone(), received_at: std::time::Instant::now(), prompt_tag: "test".into(), @@ -8051,7 +8070,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(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::CancelDrainTimeout(grace), batch: Some(batch), @@ -8181,7 +8200,9 @@ 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(scope::SessionScope::Conversation { + channel_id: Uuid::new_v4(), + }), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::CancelDrainTimeout(grace), // Explicit Stop already dropped the batch upstream in @@ -8325,6 +8346,7 @@ mod error_outcome_emission_tests { let channel_id = uuid::Uuid::new_v4(); let batch = FlushBatch { channel_id, + scope: scope::SessionScope::Conversation { channel_id }, events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -8368,7 +8390,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(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::Error(auth_error), batch: Some(batch), @@ -8394,7 +8416,7 @@ mod error_outcome_emission_tests { "auth error must dead-letter immediately — batch must not be requeued" ); assert_eq!( - queue.queued_event_count(&channel_id), + queue.queued_event_count(channel_id), 0, "auth error must dead-letter immediately — no events should be pending" ); @@ -8411,6 +8433,7 @@ mod error_outcome_emission_tests { let channel_id = uuid::Uuid::new_v4(); let batch = FlushBatch { channel_id, + scope: scope::SessionScope::Conversation { channel_id }, events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -8454,7 +8477,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(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::Error(usage_error), batch: Some(batch), @@ -8480,7 +8503,7 @@ mod error_outcome_emission_tests { "non-auth application error must requeue the batch for retry" ); assert_eq!( - queue.queued_event_count(&channel_id), + queue.queued_event_count(channel_id), 1, "non-auth application error must preserve the event for retry" ); diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 38749577398..34e93fcc81e 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -41,6 +41,7 @@ use crate::queue::{ PromptProfile, PromptProfileLookup, ThreadTags, }; use crate::relay::{ChannelInfo, RestClient}; +use crate::scope::SessionScope; /// Window within which agent activity before a hard-cap death qualifies /// the turn as "recently active" (eligible for requeue instead of dead-letter). @@ -141,8 +142,8 @@ impl SessionState { /// 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(scope) => { + self.invalidate_channel(&scope.channel_id()); } PromptSource::Heartbeat => { self.heartbeat_session = None; @@ -312,12 +313,27 @@ pub struct PromptResult { } /// Whether the prompt came from a channel event or a heartbeat. +/// +/// The channel variant carries the full [`SessionScope`] resolved at admission +/// (conversation or thread), not just the channel id, so completion and +/// invalidation target the exact session. Use [`channel_id`](PromptSource::channel_id) +/// where only the channel is needed. #[derive(Debug)] pub enum PromptSource { - Channel(Uuid), + Channel(SessionScope), Heartbeat, } +impl PromptSource { + /// The channel this prompt belongs to, or `None` for heartbeats. + pub fn channel_id(&self) -> Option { + match self { + Self::Channel(scope) => Some(scope.channel_id()), + Self::Heartbeat => None, + } + } +} + /// Apply state effects for Race 1, where a control signal arrives just after the /// prompt completed naturally. The prompt result has already been consumed by /// `select!`, so the harness must synthesize a successful result while still @@ -1752,13 +1768,10 @@ 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(b.scope.clone()), None => PromptSource::Heartbeat, }; - let observer_channel_id = match &source { - PromptSource::Channel(channel_id) => Some(*channel_id), - PromptSource::Heartbeat => None, - }; + let observer_channel_id = source.channel_id(); let turn_started_at = chrono::Utc::now().to_rfc3339(); agent.acp.set_observer_context(observer::context_for_turn( observer_channel_id, @@ -1855,9 +1868,12 @@ 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(scope), Some(owner_pk)) = (&source, ctx.agent_owner_pubkey.as_ref()) { + // Pool session state is still channel-keyed in this commit (Step 3 + // rekeys it by scope); index by the scope's channel for now. + let cid = &scope.channel_id(); let is_new_channel_session = !agent.state.sessions.contains_key(cid); if is_new_channel_session && !agent.state.core_sections.contains_key(cid) { // Bounded — we'd rather start the session with no core hint @@ -1911,7 +1927,8 @@ pub async fn run_prompt_task( // canvas DM check uses — see `resolve_new_session_channel_context`. let mut title_channel: Option = None; let mut origin_channel_type: Option = None; - if let PromptSource::Channel(cid) = &source { + if let PromptSource::Channel(scope) = &source { + let cid = &scope.channel_id(); let is_new_channel_session = !agent.state.sessions.contains_key(cid); let needs_canvas = is_new_channel_session && !agent.state.canvas_sections.contains_key(cid); if is_new_channel_session { @@ -1936,24 +1953,25 @@ 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(scope) => agent.state.core_sections.get(&scope.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(scope) => agent .state .canvas_sections - .get(cid) + .get(&scope.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) => { + PromptSource::Channel(scope) => { + let cid = &scope.channel_id(); if let Some(sid) = agent.state.sessions.get(cid) { (sid.clone(), false) } else { @@ -2114,17 +2132,19 @@ pub async fn run_prompt_task( // sessions created before this field existed fail safe by behaving as // undelivered once, rather than silently omitting standing context. let mut standing_context_sent = match &source { - PromptSource::Channel(cid) => agent + PromptSource::Channel(scope) => agent .state .deliveries - .get(cid) + .get(&scope.channel_id()) .is_some_and(|delivery| delivery.standing_context_sent), PromptSource::Heartbeat => agent.state.heartbeat_standing_context_sent, }; if is_new_session { - if let (PromptSource::Channel(cid), Some(ref initial_msg)) = (&source, &ctx.initial_message) + if let (PromptSource::Channel(scope), Some(ref initial_msg)) = + (&source, &ctx.initial_message) { + let cid = &scope.channel_id(); tracing::info!( target: "pool::session", "sending initial_message to session {session_id} for channel {cid}" @@ -2595,11 +2615,11 @@ pub async fn run_prompt_task( ); } log_stop_reason(&source, &StopReason::EndTurn); - if let PromptSource::Channel(cid) = &source { + if let PromptSource::Channel(scope) = &source { let standing_sent = !agent.has_system_prompt_support(); record_channel_delivery_success( &mut agent, - *cid, + scope.channel_id(), standing_sent, &pending_delivered_event_ids, ); @@ -2638,11 +2658,11 @@ pub async fn run_prompt_task( Ok(stop_reason) => { log_stop_reason(&source, &stop_reason); - if let PromptSource::Channel(cid) = &source { + if let PromptSource::Channel(scope) = &source { let standing_sent = !agent.has_system_prompt_support(); record_channel_delivery_success( &mut agent, - *cid, + scope.channel_id(), standing_sent, &pending_delivered_event_ids, ); @@ -2659,8 +2679,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(scope) => { + let count = agent + .state + .turn_counts + .entry(scope.channel_id()) + .or_insert(0); *count += 1; *count >= limit } @@ -4036,7 +4060,11 @@ fn classify_control_cancel_failure( /// Shared by the turn-start and turn-stop lines so a log can be read as pairs. fn prompt_label(source: &PromptSource) -> String { match source { - PromptSource::Channel(cid) => format!("channel {cid}"), + PromptSource::Channel(scope) => format!( + "channel {} ({})", + scope.channel_id(), + scope.telemetry_label() + ), PromptSource::Heartbeat => "heartbeat".to_string(), } } @@ -5853,8 +5881,10 @@ mod tests { .sign_with_keys(&keys) .unwrap(); let author_hex = event.pubkey.to_hex(); + let channel_id = Uuid::new_v4(); let batch = FlushBatch { - channel_id: Uuid::new_v4(), + channel_id, + scope: SessionScope::Conversation { channel_id }, events: vec![crate::queue::BatchEvent { event, prompt_tag: "@mention".into(), @@ -6102,6 +6132,7 @@ done"# let event_id = event.id.to_hex(); let batch = FlushBatch { channel_id, + scope: SessionScope::Conversation { channel_id }, events: vec![crate::queue::BatchEvent { event, prompt_tag: "test".into(), @@ -6184,6 +6215,7 @@ done"# .unwrap(); let merged_batch = FlushBatch { channel_id, + scope: SessionScope::Conversation { channel_id }, events: vec![crate::queue::BatchEvent { event: new_event.clone(), prompt_tag: "test".into(), @@ -6198,6 +6230,7 @@ done"# }; let next_batch = FlushBatch { channel_id, + scope: SessionScope::Conversation { channel_id }, events: vec![crate::queue::BatchEvent { event: next_event, prompt_tag: "test".into(), @@ -6353,6 +6386,7 @@ done"# .unwrap(); let batch = FlushBatch { channel_id, + scope: SessionScope::Conversation { channel_id }, events: vec![crate::queue::BatchEvent { event: trigger, prompt_tag: "test".into(), @@ -6660,7 +6694,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" apply_completed_before_control_signal( &mut s, - &PromptSource::Channel(ch_a), + &PromptSource::Channel(SessionScope::Conversation { channel_id: ch_a }), &ControlSignal::Rotate, ); @@ -6681,7 +6715,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" apply_completed_before_control_signal( &mut s, - &PromptSource::Channel(ch_a), + &PromptSource::Channel(SessionScope::Conversation { channel_id: ch_a }), &ControlSignal::Cancel, ); @@ -6694,7 +6728,9 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" #[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)); + s.invalidate(&PromptSource::Channel(SessionScope::Conversation { + channel_id: ch_a, + })); assert!(!s.sessions.contains_key(&ch_a)); assert!(!s.turn_counts.contains_key(&ch_a)); @@ -6742,7 +6778,9 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" 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(SessionScope::Conversation { + channel_id: ghost, + })); // Everything still intact. assert_eq!(s.sessions.len(), 2); @@ -6817,7 +6855,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" // 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(SessionScope::Conversation { channel_id: ch_a }), &ControlSignal::SwitchModel { model_id: "gpt-5".into(), request_id: None, @@ -6845,6 +6883,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" .unwrap(); FlushBatch { channel_id, + scope: SessionScope::Conversation { channel_id }, events: vec![crate::queue::BatchEvent { event, prompt_tag: "test".into(), diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 60866518bad..29e0871ad3c 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -19,10 +19,62 @@ use std::time::{Duration, Instant}; use uuid::Uuid; use crate::config::DedupMode; +use crate::scope::SessionScope; -/// Maximum events queued per channel before oldest events are dropped. +/// Maximum events queued per session scope before oldest events are dropped. +/// +/// Under the `channel` policy there is exactly one scope per channel, so this +/// is the historical per-channel cap. Under the `thread` policy it caps each +/// thread partition; the channel as a whole is additionally bounded by +/// [`MAX_PENDING_PER_CHANNEL`] so per-thread partitioning cannot multiply the +/// total admitted backlog. +const MAX_PENDING_PER_SCOPE: usize = 500; + +/// Aggregate cap on events queued across ALL scopes of a single channel. +/// +/// Preserves the pre-thread-scoping backlog protection: moving the per-scope +/// limit to “per thread” must not let one channel with many threads hold an +/// unbounded multiple of the old cap. Equal to [`MAX_PENDING_PER_SCOPE`] so a +/// single-scope channel behaves exactly as before. const MAX_PENDING_PER_CHANNEL: usize = 500; +/// A key that identifies a queue partition (session scope). +/// +/// Lets the queue's public API accept either a bare channel [`Uuid`] (treated +/// as a conversation scope — the pre-thread-scoping default, and what the +/// queue's own unit tests use) or an explicit [`SessionScope`] (what the +/// harness passes once a thread scope has been resolved at admission). This +/// keeps the large existing channel-keyed test suite compiling unchanged while +/// the hot path routes by full scope. +pub trait IntoScope { + /// Convert into the owned [`SessionScope`] used as the partition key. + fn into_scope(self) -> SessionScope; +} + +impl IntoScope for SessionScope { + fn into_scope(self) -> SessionScope { + self + } +} + +impl IntoScope for &SessionScope { + fn into_scope(self) -> SessionScope { + self.clone() + } +} + +impl IntoScope for Uuid { + fn into_scope(self) -> SessionScope { + SessionScope::Conversation { channel_id: self } + } +} + +impl IntoScope for &Uuid { + fn into_scope(self) -> SessionScope { + SessionScope::Conversation { channel_id: *self } + } +} + /// Maximum events drained into a single batch. const MAX_BATCH_EVENTS: usize = 50; @@ -45,6 +97,11 @@ const DEFAULT_IN_FLIGHT_DEADLINE_SECS: u64 = 7300; #[derive(Debug, Clone)] pub struct QueuedEvent { pub channel_id: Uuid, + /// Session scope resolved once at admission. Under `channel` policy this is + /// always `Conversation { channel_id }`; under `thread` policy it is the + /// canonical thread scope. The queue partitions on this, never on the + /// channel alone. Invariant: `scope.channel_id() == channel_id`. + pub scope: SessionScope, pub event: Event, pub received_at: Instant, /// Tag identifying which rule (or mode) matched this event. @@ -76,6 +133,9 @@ pub enum CancelReason { #[derive(Debug, Clone)] pub struct FlushBatch { pub channel_id: Uuid, + /// The single session scope every event in this batch belongs to. Events + /// from different scopes are never combined into one batch. + pub scope: SessionScope, pub events: Vec, /// Events from a cancelled batch that triggered this re-prompt. /// Empty for normal (non-cancel) batches. When non-empty, `format_prompt()` @@ -135,24 +195,24 @@ pub struct FlushBatch { /// 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, /// 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). + cancelled_batches: HashMap>, + /// Why each scope'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, + /// `FlushBatch::cancel_reason`. Keyed by scope, cleared on flush. + cancel_reasons: 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 @@ -163,7 +223,7 @@ pub struct EventQueue { /// at line 453). 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>, + withheld_native_steer: HashMap>, /// Duration after which an in-flight channel 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. @@ -179,7 +239,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(), @@ -207,13 +267,15 @@ impl EventQueue { /// moves backward. If the channel is not in-flight (already completed /// via `mark_complete`), this is a no-op: a late ack never resurrects /// a deadline. - pub fn extend_in_flight_deadline(&mut self, channel_id: Uuid, max_turn_secs: u64) { - if let Some(current) = self.in_flight_deadlines.get_mut(&channel_id) { + pub fn extend_in_flight_deadline(&mut self, scope: K, max_turn_secs: u64) { + let scope = scope.into_scope(); + if let Some(current) = self.in_flight_deadlines.get_mut(&scope) { let extended = Instant::now() + Duration::from_secs(max_turn_secs + IN_FLIGHT_DEADLINE_BUFFER_SECS); if extended > *current { tracing::info!( - %channel_id, + channel_id = %scope.channel_id(), + scope = %scope.telemetry_label(), "extending in-flight deadline by {max_turn_secs}s + {IN_FLIGHT_DEADLINE_BUFFER_SECS}s buffer" ); *current = extended; @@ -228,29 +290,77 @@ impl EventQueue { /// /// Returns `true` if the event was accepted, `false` if dropped. pub fn push(&mut self, event: QueuedEvent) -> bool { + debug_assert_eq!( + event.scope.channel_id(), + event.channel_id, + "QueuedEvent.scope must belong to its channel_id" + ); if matches!(self.dedup_mode, DedupMode::Drop) - && self.in_flight_channels.contains(&event.channel_id) + && self.in_flight_scopes.contains(&event.scope) { tracing::debug!( channel_id = %event.channel_id, - "dropping event for in-flight channel (drop mode)" + scope = %event.scope.telemetry_label(), + "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 channel_id = event.channel_id; + let scope = event.scope.clone(); + let queue = self.queues.entry(scope.clone()).or_default(); + // Enforce per-scope depth cap: drop oldest in this partition. + if queue.len() >= MAX_PENDING_PER_SCOPE { queue.pop_front(); tracing::warn!( - channel_id = %event.channel_id, - limit = MAX_PENDING_PER_CHANNEL, - "queue depth cap reached — dropped oldest event" + channel_id = %channel_id, + scope = %scope.telemetry_label(), + limit = MAX_PENDING_PER_SCOPE, + "per-scope queue depth cap reached — dropped oldest event" ); } queue.push_back(event); + // Enforce the aggregate per-channel cap across all scopes so thread + // partitioning cannot multiply the admitted backlog. + self.enforce_channel_cap(channel_id); true } + /// Total queued events across every scope belonging to `channel_id`. + fn channel_event_total(&self, channel_id: Uuid) -> usize { + self.queues + .iter() + .filter(|(s, _)| s.channel_id() == channel_id) + .map(|(_, q)| q.len()) + .sum() + } + + /// Drop the globally-oldest queued event(s) across a channel's scopes until + /// its aggregate depth is within [`MAX_PENDING_PER_CHANNEL`]. Preserves + /// cross-scope FIFO fairness by always evicting the oldest head event. + fn enforce_channel_cap(&mut self, channel_id: Uuid) { + while self.channel_event_total(channel_id) > MAX_PENDING_PER_CHANNEL { + // Find the channel's scope whose head event is oldest. + let victim = self + .queues + .iter() + .filter(|(s, q)| s.channel_id() == channel_id && !q.is_empty()) + .min_by_key(|(_, q)| q.front().unwrap().received_at) + .map(|(s, _)| s.clone()); + let Some(scope) = victim else { break }; + if let Some(q) = self.queues.get_mut(&scope) { + q.pop_front(); + if q.is_empty() { + self.queues.remove(&scope); + } + } + tracing::warn!( + channel_id = %channel_id, + limit = MAX_PENDING_PER_CHANNEL, + "aggregate per-channel queue cap reached — dropped oldest event" + ); + } + } + /// Try to flush the next batch. /// /// Returns `None` if all non-in-flight, non-throttled queues are empty. @@ -261,67 +371,70 @@ impl EventQueue { let now = Instant::now(); // Auto-expire any stuck in-flight entries that missed mark_complete. - let expired: Vec = self + let expired: Vec = self .in_flight_deadlines .iter() .filter(|(_, deadline)| now >= **deadline) - .map(|(id, _)| *id) + .map(|(scope, _)| scope.clone()) .collect(); - for id in expired { - let lost_events = self.in_flight_batch_sizes.remove(&id).unwrap_or(0); + for scope in expired { + let lost_events = self.in_flight_batch_sizes.remove(&scope).unwrap_or(0); tracing::error!( - channel_id = %id, + channel_id = %scope.channel_id(), + scope = %scope.telemetry_label(), lost_events, deadline_secs = self.in_flight_deadline.as_secs(), - "BUG: in-flight channel expired without mark_complete — \ + "BUG: in-flight scope expired without mark_complete — \ auto-releasing; {lost_events} dispatched event(s) orphaned" ); - self.in_flight_channels.remove(&id); - self.in_flight_deadlines.remove(&id); + self.in_flight_scopes.remove(&scope); + self.in_flight_deadlines.remove(&scope); // Recover any withheld goose-native steer events for the expired - // channel back to the queue front so normal dispatch delivers + // 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_channel(id); + self.recover_withheld_for_expired_scope(&scope); } - // 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(|(scope, 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(scope) + && self.retry_after.get(scope).is_none_or(|&t| t <= now) }) .min_by_key(|(_, q)| q.front().unwrap().received_at) - .map(|(id, _)| *id); + .map(|(scope, _)| scope.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(scope) => scope, None => { - let cancelled_id = self + let cancelled_scope = self .cancelled_batches .keys() - .find(|id| !self.in_flight_channels.contains(id)) - .copied(); - match cancelled_id { - Some(id) => { + .find(|scope| !self.in_flight_scopes.contains(scope)) + .cloned(); + match cancelled_scope { + Some(scope) => { // 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); - self.in_flight_channels.insert(id); + let cancelled = self.cancelled_batches.remove(&scope).unwrap_or_default(); + let cancel_reason = self.cancel_reasons.remove(&scope); + self.in_flight_scopes.insert(scope.clone()); self.in_flight_deadlines - .insert(id, now + self.in_flight_deadline); - self.in_flight_batch_sizes.insert(id, cancelled.len()); + .insert(scope.clone(), now + self.in_flight_deadline); + self.in_flight_batch_sizes + .insert(scope.clone(), cancelled.len()); return Some(FlushBatch { - channel_id: id, + channel_id: scope.channel_id(), + scope, events: cancelled, cancelled_events: vec![], cancel_reason, @@ -331,9 +444,10 @@ impl EventQueue { } } }; + let channel_id = scope.channel_id(); // Drain up to MAX_BATCH_EVENTS; leave any remainder in the queue. - let queue = self.queues.entry(channel_id).or_default(); + 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) @@ -350,29 +464,28 @@ 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 any cancelled events stored by requeue_as_cancelled(). - let cancelled_events = self - .cancelled_batches - .remove(&channel_id) - .unwrap_or_default(); + let cancelled_events = self.cancelled_batches.remove(&scope).unwrap_or_default(); let cancel_reason = if cancelled_events.is_empty() { - self.cancel_reasons.remove(&channel_id); + self.cancel_reasons.remove(&scope); None } else { - self.cancel_reasons.remove(&channel_id) + self.cancel_reasons.remove(&scope) }; Some(FlushBatch { channel_id, + scope, events, cancelled_events, cancel_reason, @@ -389,22 +502,23 @@ impl EventQueue { /// 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: K) { + let scope = scope.into_scope(); + 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); } } } @@ -428,8 +542,9 @@ impl EventQueue { /// `mark_complete` separately. pub fn requeue(&mut self, batch: FlushBatch) -> Option { let channel_id = batch.channel_id; + let scope = batch.scope.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 }; @@ -443,10 +558,10 @@ impl EventQueue { 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); } @@ -472,28 +587,31 @@ 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 { channel_id, + scope: scope.clone(), event: be.event, prompt_tag: be.prompt_tag, received_at: be.received_at, // preserve original timestamp (#46) }); } - // Enforce per-channel 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 { + // Enforce per-scope cap: trim oldest (back) events if requeue pushed + // the partition over the limit. Without this, repeated requeue+push + // cycles can grow the queue unboundedly. + while queue.len() > MAX_PENDING_PER_SCOPE { queue.pop_back(); tracing::warn!( channel_id = %channel_id, - limit = MAX_PENDING_PER_CHANNEL, + scope = %scope.telemetry_label(), + 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); + self.enforce_channel_cap(channel_id); None } @@ -507,25 +625,29 @@ impl EventQueue { /// caller must call `mark_complete` separately. pub fn requeue_preserve_timestamps(&mut self, batch: FlushBatch) { let channel_id = batch.channel_id; - let queue = self.queues.entry(channel_id).or_default(); + let scope = batch.scope.clone(); + 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 { channel_id, + scope: scope.clone(), event: be.event, prompt_tag: be.prompt_tag, received_at: be.received_at, }); } - // 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, + scope = %scope.telemetry_label(), + limit = MAX_PENDING_PER_SCOPE, "requeue_preserve overflow — dropped newest event to enforce cap" ); } + self.enforce_channel_cap(channel_id); } /// Requeue a cancelled batch so its events appear as `cancelled_events` @@ -540,11 +662,12 @@ 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(); + let scope = batch.scope.clone(); + let entry = self.cancelled_batches.entry(scope.clone()).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); + self.cancel_reasons.insert(scope, reason); } /// Returns `true` if any channel has pending events that are not in-flight @@ -557,37 +680,38 @@ impl EventQueue { let now = Instant::now(); // Auto-expire stuck in-flight entries (same logic as flush_next). - let expired: Vec = self + let expired: Vec = self .in_flight_deadlines .iter() .filter(|(_, deadline)| now >= **deadline) - .map(|(id, _)| *id) + .map(|(scope, _)| scope.clone()) .collect(); - for id in expired { - let lost_events = self.in_flight_batch_sizes.remove(&id).unwrap_or(0); + for scope in expired { + let lost_events = self.in_flight_batch_sizes.remove(&scope).unwrap_or(0); tracing::error!( - channel_id = %id, + channel_id = %scope.channel_id(), + scope = %scope.telemetry_label(), lost_events, deadline_secs = self.in_flight_deadline.as_secs(), - "BUG: in-flight channel expired without mark_complete — \ + "BUG: in-flight scope expired without mark_complete — \ auto-releasing; {lost_events} dispatched event(s) orphaned" ); - self.in_flight_channels.remove(&id); - self.in_flight_deadlines.remove(&id); + self.in_flight_scopes.remove(&scope); + self.in_flight_deadlines.remove(&scope); // Symmetric with the flush_next expiry block: recover withheld - // goose-native steer events for the expired channel so they are + // goose-native steer events for the expired scope so they are // not permanently orphaned in the side table. - self.recover_withheld_for_expired_channel(id); + self.recover_withheld_for_expired_scope(&scope); } - self.queues.iter().any(|(id, q)| { + self.queues.iter().any(|(scope, 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(scope) + && self.retry_after.get(scope).is_none_or(|&t| t <= now) }) || self .cancelled_batches .keys() - .any(|id| !self.in_flight_channels.contains(id)) + .any(|scope| !self.in_flight_scopes.contains(scope)) } /// Returns `true` if any undispatched work remains for a channel that is @@ -611,27 +735,31 @@ impl EventQueue { let has_queued = self .queues .iter() - .any(|(id, q)| !q.is_empty() && !self.in_flight_channels.contains(id)); + .any(|(scope, q)| !q.is_empty() && !self.in_flight_scopes.contains(scope)); let has_cancelled = self .cancelled_batches .keys() - .any(|id| !self.in_flight_channels.contains(id)); + .any(|scope| !self.in_flight_scopes.contains(scope)); let has_withheld = self .withheld_native_steer .iter() - .any(|(id, v)| !v.is_empty() && !self.in_flight_channels.contains(id)); + .any(|(scope, v)| !v.is_empty() && !self.in_flight_scopes.contains(scope)); has_queued || has_cancelled || has_withheld } - /// Number of channels with pending events. + /// Number of pending partitions (session scopes) with queued events. + /// + /// Under `channel` policy this equals the number of channels with pending + /// events; under `thread` policy it counts distinct thread partitions. 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 (or channel, treated as its + /// conversation 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: K) -> usize { + self.queues.get(&scope.into_scope()).map_or(0, |q| q.len()) } /// Force a channel's retry-attempt counter to `count`, simulating `count` @@ -640,8 +768,8 @@ impl EventQueue { /// Test-only — lets integration tests outside this module exercise /// `requeue()`'s dead-letter threshold directly. #[cfg(test)] - pub fn set_retry_count_for_test(&mut self, channel_id: Uuid, count: u32) { - self.retry_counts.insert(channel_id, count); + pub fn set_retry_count_for_test(&mut self, scope: K, count: u32) { + self.retry_counts.insert(scope.into_scope(), count); } /// Drop all queued (non-in-flight) events for a channel. @@ -656,32 +784,47 @@ impl EventQueue { /// 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 + // Channel-wide cleanup must find and clear EVERY child thread scope for + // this channel, not just the conversation scope. + let scopes: Vec = 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.cancel_reasons.remove(&channel_id); - self.withheld_native_steer.remove(&channel_id); - // Preserve in_flight_channels AND in_flight_deadlines: the in-flight + .keys() + .filter(|s| s.channel_id() == channel_id) + .cloned() + .collect(); + let mut ids = Vec::new(); + for scope in &scopes { + if let Some(q) = self.queues.remove(scope) { + ids.extend(q.into_iter().map(|e| e.event.id.to_hex())); + } + } + // Also purge side-tables for every scope of this channel. + self.retry_after.retain(|s, _| s.channel_id() != channel_id); + self.retry_counts + .retain(|s, _| s.channel_id() != channel_id); + self.cancelled_batches + .retain(|s, _| s.channel_id() != channel_id); + self.cancel_reasons + .retain(|s, _| s.channel_id() != channel_id); + self.withheld_native_steer + .retain(|s, _| s.channel_id() != channel_id); + // Preserve in_flight_scopes 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. + // 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 (or channel, + /// treated as its conversation scope). + pub fn is_scope_in_flight(&self, scope: K) -> bool { + self.in_flight_scopes.contains(&scope.into_scope()) } - /// Whether any channel currently has a turn in flight. + /// Whether any scope currently has a turn in flight. pub fn has_in_flight(&self) -> bool { - !self.in_flight_channels.is_empty() + !self.in_flight_scopes.is_empty() } // ── Goose-native steer withhold (side table) ────────────────────────── @@ -708,8 +851,9 @@ impl EventQueue { /// 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: K, event_id: &str) -> bool { + let scope = scope.into_scope(); + 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 { @@ -719,10 +863,10 @@ 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) .or_default() .push(qe); true @@ -738,8 +882,9 @@ impl EventQueue { /// /// 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 { + pub fn release_native_steer(&mut self, scope: K, event_id: &str) { + let scope = scope.into_scope(); + let Some(entries) = self.withheld_native_steer.get_mut(&scope) else { return; }; let Some(pos) = entries @@ -750,21 +895,24 @@ impl EventQueue { }; let qe = entries.remove(pos); if entries.is_empty() { - self.withheld_native_steer.remove(&channel_id); + self.withheld_native_steer.remove(&scope); } + let channel_id = scope.channel_id(); // 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, + scope = %scope.telemetry_label(), + limit = MAX_PENDING_PER_SCOPE, "release_native_steer overflow — dropped newest event to enforce cap" ); } + self.enforce_channel_cap(channel_id); } /// Drop a specific event by id from both the side table and the main @@ -773,17 +921,18 @@ 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: K, event_id: &str) { + let scope = scope.into_scope(); + 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); } } } @@ -801,25 +950,29 @@ impl EventQueue { /// 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 { + fn recover_withheld_for_expired_scope(&mut self, scope: &SessionScope) { + 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 channel_id = scope.channel_id(); + 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, + scope = %scope.telemetry_label(), + limit = MAX_PENDING_PER_SCOPE, "withheld-steer recovery overflow — dropped newest event to enforce cap" ); } + self.enforce_channel_cap(channel_id); tracing::warn!( channel_id = %channel_id, + scope = %scope.telemetry_label(), recovered = n, "in-flight expiry recovered withheld steer event(s) — \ steer ack never arrived; normal dispatch will deliver" @@ -848,10 +1001,10 @@ impl EventQueue { // Remove retry_counts for channels 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(|scope, _| { + self.retry_after.contains_key(scope) + || self.queues.get(scope).is_some_and(|q| !q.is_empty()) + || self.in_flight_scopes.contains(scope) }); } } @@ -1771,10 +1924,17 @@ mod tests { .unwrap() } - /// Build a QueuedEvent for the given channel. + /// Conversation scope for a channel — the default scope the queue's own + /// unit tests exercise (equivalent to the pre-thread-scoping channel key). + fn conv(channel_id: Uuid) -> SessionScope { + SessionScope::Conversation { channel_id } + } + + /// Build a QueuedEvent for the given channel (conversation scope). fn make_queued(channel_id: Uuid, content: &str) -> QueuedEvent { QueuedEvent { channel_id, + scope: conv(channel_id), event: make_event(content), received_at: Instant::now(), prompt_tag: "test".into(), @@ -1785,6 +1945,7 @@ mod tests { fn make_queued_at(channel_id: Uuid, content: &str, age: Duration) -> QueuedEvent { QueuedEvent { channel_id, + scope: conv(channel_id), event: make_event(content), received_at: Instant::now() - age, prompt_tag: "test".into(), @@ -1805,6 +1966,7 @@ mod tests { .unwrap(); QueuedEvent { channel_id, + scope: conv(channel_id), event, received_at: Instant::now(), prompt_tag: "test".into(), @@ -1816,7 +1978,145 @@ mod tests { } fn any_in_flight(q: &EventQueue) -> bool { - !q.in_flight_channels.is_empty() + !q.in_flight_scopes.is_empty() + } + + /// Thread scope within a channel, keyed by a synthetic 64-hex root. + fn thread(channel_id: Uuid, root: &str) -> SessionScope { + SessionScope::Thread { + channel_id, + root_event_id: root.to_string(), + } + } + + /// Build a QueuedEvent for an explicit scope. + fn make_scoped(scope: SessionScope, content: &str) -> QueuedEvent { + QueuedEvent { + channel_id: scope.channel_id(), + scope, + event: make_event(content), + received_at: Instant::now(), + prompt_tag: "test".into(), + } + } + + // ── Step 2: scope partitioning ────────────────────────────────────────── + + #[test] + fn two_threads_in_one_channel_are_independent_partitions() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + let ta = thread(ch, &"a".repeat(64)); + let tb = thread(ch, &"b".repeat(64)); + q.push(make_scoped(ta.clone(), "thread-a")); + q.push(make_scoped(tb.clone(), "thread-b")); + + // First flush claims one thread; the other is still flushable because + // it is a distinct scope in the same channel. + let first = q.flush_next().expect("first batch"); + assert_eq!(first.channel_id, ch); + assert!(first.scope.is_thread()); + assert!(q.is_scope_in_flight(&first.scope)); + + // The sibling thread is NOT blocked by the first thread's in-flight turn. + let second = q.flush_next().expect("second batch"); + assert_eq!(second.channel_id, ch); + assert_ne!(first.scope, second.scope); + // Batches never mix scopes. + assert_eq!(first.events.len(), 1); + assert_eq!(second.events.len(), 1); + } + + #[test] + fn events_from_different_roots_never_share_a_batch() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + let ta = thread(ch, &"a".repeat(64)); + let tb = thread(ch, &"b".repeat(64)); + // Interleave pushes across the two thread scopes. + q.push(make_scoped(ta.clone(), "a1")); + q.push(make_scoped(tb.clone(), "b1")); + q.push(make_scoped(ta.clone(), "a2")); + q.push(make_scoped(tb.clone(), "b2")); + + let batch = q.flush_next().expect("batch"); + // Every event in the drained batch belongs to the single flushed scope. + let contents: Vec<&str> = batch + .events + .iter() + .map(|e| e.event.content.as_str()) + .collect(); + if batch.scope == ta { + assert_eq!(contents, vec!["a1", "a2"]); + } else { + assert_eq!(contents, vec!["b1", "b2"]); + } + } + + #[test] + fn in_flight_scope_blocks_only_that_scope_not_the_channel() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + let ta = thread(ch, &"a".repeat(64)); + q.push(make_scoped(ta.clone(), "a1")); + let _b = q.flush_next().expect("flush a"); + assert!(q.is_scope_in_flight(&ta)); + + // A new event on the SAME thread is blocked while in-flight (queue mode + // keeps it, but it is not re-flushable until mark_complete). + q.push(make_scoped(ta.clone(), "a2")); + assert!(q.flush_next().is_none()); + + // A new event on a DIFFERENT thread flushes immediately. + let tb = thread(ch, &"b".repeat(64)); + q.push(make_scoped(tb.clone(), "b1")); + let batch = q.flush_next().expect("sibling flushes"); + assert_eq!(batch.scope, tb); + + // Completing thread A unblocks its queued event. + q.mark_complete(ta.clone()); + let batch = q.flush_next().expect("a2 flushes after complete"); + assert_eq!(batch.scope, ta); + assert_eq!(batch.events[0].event.content, "a2"); + } + + #[test] + fn drain_channel_clears_every_child_thread_scope() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + let other = Uuid::new_v4(); + q.push(make_scoped(thread(ch, &"a".repeat(64)), "a1")); + q.push(make_scoped(thread(ch, &"b".repeat(64)), "b1")); + q.push(make_scoped(conv(ch), "conv")); + q.push(make_scoped(thread(other, &"c".repeat(64)), "other")); + + let dropped = q.drain_channel(ch); + assert_eq!(dropped.len(), 3, "all three ch scopes drained"); + // The other channel's thread survives. + let batch = q.flush_next().expect("other channel still has work"); + assert_eq!(batch.channel_id, other); + } + + #[test] + fn aggregate_channel_cap_not_multiplied_by_threads() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + // Spread well over the aggregate cap across many thread scopes. + let total = MAX_PENDING_PER_CHANNEL + 250; + for i in 0..total { + let root = format!("{:064x}", i % 5); + q.push(make_scoped(thread(ch, &root), "x")); + } + let channel_total: usize = q + .queues + .iter() + .filter(|(s, _)| s.channel_id() == ch) + .map(|(_, v)| v.len()) + .sum(); + assert!( + channel_total <= MAX_PENDING_PER_CHANNEL, + "aggregate per-channel cap must bound all thread scopes combined, got {channel_total}" + ); } #[test] @@ -1999,6 +2299,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -2029,6 +2330,7 @@ mod tests { let ch = Uuid::new_v4(); FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event: make_event("the new message"), prompt_tag: "@mention".into(), @@ -2160,6 +2462,7 @@ mod tests { let ch = Uuid::new_v4(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![ BatchEvent { event: make_event("new one"), @@ -2217,6 +2520,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event: steering, prompt_tag: "@mention".into(), @@ -2268,7 +2572,7 @@ mod tests { queue.mark_complete(ch); // retry_after is set, so manually clear it for this test. - queue.retry_after.remove(&ch); + queue.retry_after.remove(&conv(ch)); // Should be able to flush again and get the same events in order. let batch2 = queue.flush_next().unwrap(); @@ -2309,7 +2613,7 @@ mod tests { assert!( queue .retry_after - .get(&ch) + .get(&conv(ch)) .is_some_and(|&t| t > Instant::now()), "requeue must have set a future backoff deadline" ); @@ -2388,6 +2692,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![ BatchEvent { event: e1, @@ -2428,6 +2733,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2451,6 +2757,7 @@ mod tests { let event = make_event("hi"); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2483,6 +2790,7 @@ mod tests { let event = make_event("hi"); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2513,6 +2821,7 @@ mod tests { let event = make_event("hi"); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2540,6 +2849,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2564,6 +2874,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2625,6 +2936,7 @@ mod tests { let ch = Uuid::new_v4(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event: make_event("hello"), prompt_tag: "test".into(), @@ -2678,6 +2990,7 @@ mod tests { let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2716,6 +3029,7 @@ mod tests { let event = make_event("hello"); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2825,7 +3139,7 @@ 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); @@ -2924,13 +3238,13 @@ 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)); + assert_eq!(q.in_flight_scopes.len(), 1); + assert!(q.in_flight_scopes.contains(&conv(ch_b))); + assert!(!q.in_flight_scopes.contains(&conv(ch_a))); // B still in-flight. assert!(any_in_flight(&q)); @@ -2947,6 +3261,7 @@ mod tests { q.push(QueuedEvent { channel_id: ch, + scope: conv(ch), event: make_event("old-msg"), received_at: old_time, prompt_tag: "test".into(), @@ -2976,7 +3291,7 @@ mod tests { q.mark_complete(ch); // No retry_after — channel should be immediately flushable. - assert!(!q.retry_after.contains_key(&ch)); + assert!(!q.retry_after.contains_key(&conv(ch))); assert!(q.flush_next().is_some()); } @@ -3082,7 +3397,7 @@ mod tests { // Manually expire the retry_after to simulate time passing. q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(conv(ch), Instant::now() - Duration::from_secs(1)); assert!( q.has_flushable_work(), "expired throttle should be flushable" @@ -3097,7 +3412,7 @@ 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(conv(ch), Instant::now() - Duration::from_secs(1)); let batch = q.flush_next().expect("flush"); assert!( q.requeue(batch).is_none(), @@ -3108,15 +3423,15 @@ mod tests { // The MAX_RETRIES+1'th failure dead-letters: batch is returned. q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(conv(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); // 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(&conv(ch))); + assert!(!q.retry_after.contains_key(&conv(ch))); } #[test] @@ -3142,7 +3457,7 @@ mod tests { // After retry_after expires, ch should be flushable again. q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(conv(ch), Instant::now() - Duration::from_secs(1)); q.mark_complete(ch2); let batch3 = q .flush_next() @@ -3258,6 +3573,7 @@ mod tests { let event = make_event("hello"); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -3290,6 +3606,7 @@ mod tests { let event = make_event("hey"); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "dm".into(), @@ -3329,6 +3646,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -3359,6 +3677,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -3405,6 +3724,7 @@ mod tests { let event = make_event("ok do that"); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "dm".into(), @@ -3456,6 +3776,7 @@ mod tests { let author_hex = event.pubkey.to_hex(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -3664,6 +3985,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "dm".into(), @@ -3733,6 +4055,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -3766,6 +4089,7 @@ mod tests { let ch = Uuid::new_v4(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event: make_event("follow up"), prompt_tag: "dm".into(), @@ -3815,6 +4139,7 @@ mod tests { let event = make_event("hey there"); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "dm".into(), @@ -3856,6 +4181,7 @@ mod tests { let event_id = event.id.to_hex(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -3880,6 +4206,7 @@ mod tests { let npub = event.pubkey.to_bech32().unwrap(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -3903,6 +4230,7 @@ mod tests { let event = make_event_with_tags("hello", vec![vec!["h".into(), ch.to_string()]]); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -4000,25 +4328,25 @@ mod tests { 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)); + assert!(q.retry_after.contains_key(&conv(ch))); + assert!(q.retry_counts.contains_key(&conv(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(conv(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)); + assert!(!q.retry_counts.contains_key(&conv(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(conv(ch), 3); q.compact_expired_state(); assert!( - !q.retry_counts.contains_key(&ch), + !q.retry_counts.contains_key(&conv(ch)), "orphaned retry_counts should be removed" ); } @@ -4036,17 +4364,17 @@ mod tests { // Expire the throttle so the requeued event can be flushed. q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(conv(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(&conv(ch))); + assert!(q.queues.get(&conv(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(&conv(ch)), "retry_counts must survive while channel is in-flight" ); } @@ -4058,11 +4386,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(conv(ch), 2); q.compact_expired_state(); assert!( - q.retry_counts.contains_key(&ch), + q.retry_counts.contains_key(&conv(ch)), "retry_counts should survive when queue is non-empty" ); } @@ -4269,6 +4597,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -4311,6 +4640,7 @@ mod tests { let event_id = event.id.to_hex(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -4346,6 +4676,7 @@ mod tests { let event_id = event.id.to_hex(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -4375,6 +4706,7 @@ mod tests { let event = make_event("hey there"); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -4418,6 +4750,7 @@ mod tests { let event_id = event.id.to_hex(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -4454,6 +4787,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -4489,6 +4823,7 @@ mod tests { ); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![ BatchEvent { event: plain, @@ -4526,6 +4861,7 @@ mod tests { let plain_id = plain.id.to_hex(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![ BatchEvent { event: threaded, @@ -4557,8 +4893,10 @@ mod tests { /// Build a single-event FlushBatch with the given content. fn make_single_batch(content: &str) -> FlushBatch { + let channel_id = Uuid::new_v4(); FlushBatch { - channel_id: Uuid::new_v4(), + channel_id, + scope: conv(channel_id), events: vec![BatchEvent { event: make_event(content), prompt_tag: "test".into(), @@ -4693,7 +5031,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(&conv(ch)).map(|v| v.len()), + Some(1) + ); } /// Earlier events on the same channel must flush normally during the @@ -4761,9 +5102,9 @@ 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); + q.in_flight_scopes.insert(conv(ch)); + q.in_flight_deadlines.insert(conv(ch), Instant::now()); + q.in_flight_batch_sizes.insert(conv(ch), 1); assert!(q.mark_native_steer_pending(ch, &event_id)); // Force the in-flight deadline to be in the past, simulating the @@ -4771,7 +5112,7 @@ mod tests { // 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(conv(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. @@ -4823,20 +5164,23 @@ mod tests { assert!(q.mark_native_steer_pending(ch, &e2_id)); assert!(q.mark_native_steer_pending(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(&conv(ch)).map(|v| v.len()), + Some(3) + ); // Trigger expiry → bulk-release path. - q.in_flight_channels.insert(ch); + q.in_flight_scopes.insert(conv(ch)); q.in_flight_deadlines - .insert(ch, Instant::now() - Duration::from_secs(1)); - q.in_flight_batch_sizes.insert(ch, 3); + .insert(conv(ch), Instant::now() - Duration::from_secs(1)); + q.in_flight_batch_sizes.insert(conv(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(&conv(ch)) .expect("queue restored") .iter() .map(|qe| qe.event.id.to_hex()) @@ -4853,6 +5197,7 @@ mod tests { let ch = Uuid::new_v4(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event: make_event("hi"), prompt_tag: "test".into(), @@ -4882,6 +5227,7 @@ mod tests { let ch = Uuid::new_v4(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event: make_event("hi"), prompt_tag: "test".into(), @@ -4910,6 +5256,7 @@ mod tests { let ch = Uuid::new_v4(); let batch = FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event: make_event("hi"), prompt_tag: "test".into(), @@ -4958,11 +5305,11 @@ mod tests { let mut q = EventQueue::new(DedupMode::Queue); let ch = Uuid::new_v4(); let old_deadline = Instant::now() + Duration::from_secs(100); - q.in_flight_channels.insert(ch); - q.in_flight_deadlines.insert(ch, old_deadline); + q.in_flight_scopes.insert(conv(ch)); + q.in_flight_deadlines.insert(conv(ch), old_deadline); q.extend_in_flight_deadline(ch, 7200); - let new = *q.in_flight_deadlines.get(&ch).unwrap(); + let new = *q.in_flight_deadlines.get(&conv(ch)).unwrap(); assert!( new > old_deadline, "extended deadline must be past the original" @@ -4974,11 +5321,11 @@ mod tests { let mut q = EventQueue::new(DedupMode::Queue); let ch = Uuid::new_v4(); let far_future = Instant::now() + Duration::from_secs(999_999); - q.in_flight_channels.insert(ch); - q.in_flight_deadlines.insert(ch, far_future); + q.in_flight_scopes.insert(conv(ch)); + q.in_flight_deadlines.insert(conv(ch), far_future); q.extend_in_flight_deadline(ch, 7200); - let after = *q.in_flight_deadlines.get(&ch).unwrap(); + let after = *q.in_flight_deadlines.get(&conv(ch)).unwrap(); assert_eq!(after, far_future, "deadline must never move backward"); } @@ -4986,17 +5333,17 @@ mod tests { fn extend_in_flight_deadline_noop_after_mark_complete() { let mut q = EventQueue::new(DedupMode::Queue); let ch = Uuid::new_v4(); - q.in_flight_channels.insert(ch); + q.in_flight_scopes.insert(conv(ch)); q.in_flight_deadlines - .insert(ch, Instant::now() + Duration::from_secs(100)); - q.in_flight_batch_sizes.insert(ch, 1); + .insert(conv(ch), Instant::now() + Duration::from_secs(100)); + q.in_flight_batch_sizes.insert(conv(ch), 1); q.mark_complete(ch); - assert!(!q.in_flight_deadlines.contains_key(&ch)); + assert!(!q.in_flight_deadlines.contains_key(&conv(ch))); q.extend_in_flight_deadline(ch, 7200); assert!( - !q.in_flight_deadlines.contains_key(&ch), + !q.in_flight_deadlines.contains_key(&conv(ch)), "extend after mark_complete must not resurrect a deadline" ); } @@ -5006,17 +5353,17 @@ mod tests { let mut q = EventQueue::new(DedupMode::Queue); let ch = Uuid::new_v4(); let extended = Instant::now() + Duration::from_secs(9999); - q.in_flight_channels.insert(ch); - q.in_flight_deadlines.insert(ch, extended); + q.in_flight_scopes.insert(conv(ch)); + q.in_flight_deadlines.insert(conv(ch), extended); q.compact_expired_state(); assert!( - q.in_flight_deadlines.contains_key(&ch), + q.in_flight_deadlines.contains_key(&conv(ch)), "compaction must not touch in-flight deadlines" ); assert_eq!( - *q.in_flight_deadlines.get(&ch).unwrap(), + *q.in_flight_deadlines.get(&conv(ch)).unwrap(), extended, "compaction must leave extended deadline intact" ); @@ -5035,9 +5382,9 @@ mod tests { // Insert the channel as in-flight with a deadline already in the past // (Instant::now() — by the time flush_next runs, now >= deadline). - q.in_flight_channels.insert(ch); - q.in_flight_deadlines.insert(ch, Instant::now()); - q.in_flight_batch_sizes.insert(ch, 1); + q.in_flight_scopes.insert(conv(ch)); + q.in_flight_deadlines.insert(conv(ch), Instant::now()); + q.in_flight_batch_sizes.insert(conv(ch), 1); // Also push an event so flush_next has something to do after expiry. q.push(make_queued(ch, "after-expiry")); @@ -5063,10 +5410,10 @@ mod tests { let ch = Uuid::new_v4(); // Put the channel in-flight with an extended deadline far in the future. - q.in_flight_channels.insert(ch); + q.in_flight_scopes.insert(conv(ch)); q.in_flight_deadlines - .insert(ch, Instant::now() + Duration::from_secs(9999)); - q.in_flight_batch_sizes.insert(ch, 1); + .insert(conv(ch), Instant::now() + Duration::from_secs(9999)); + q.in_flight_batch_sizes.insert(conv(ch), 1); // Push an event for another channel so flush_next has work to do. let ch2 = Uuid::new_v4(); @@ -5080,11 +5427,11 @@ mod tests { // ch must still be in-flight — the extended deadline did not expire. assert!( - q.in_flight_channels.contains(&ch), + q.in_flight_scopes.contains(&conv(ch)), "ch must remain in-flight after flush_next with an extended deadline" ); assert!( - q.in_flight_deadlines.contains_key(&ch), + q.in_flight_deadlines.contains_key(&conv(ch)), "in-flight deadline for ch must not be removed by flush_next" ); } @@ -5101,10 +5448,10 @@ mod tests { let ch = Uuid::new_v4(); // In-flight channel with extended (far-future) deadline. - q.in_flight_channels.insert(ch); + q.in_flight_scopes.insert(conv(ch)); q.in_flight_deadlines - .insert(ch, Instant::now() + Duration::from_secs(9999)); - q.in_flight_batch_sizes.insert(ch, 1); + .insert(conv(ch), Instant::now() + Duration::from_secs(9999)); + q.in_flight_batch_sizes.insert(conv(ch), 1); // No other channels — nothing flushable. assert!( @@ -5112,7 +5459,7 @@ mod tests { "has_flushable_work must return false when the only channel is in-flight with extended deadline" ); assert!( - q.in_flight_channels.contains(&ch), + q.in_flight_scopes.contains(&conv(ch)), "ch must remain in-flight after has_flushable_work with extended deadline" ); @@ -5125,7 +5472,7 @@ mod tests { ); // ch still in-flight and not expired. assert!( - q.in_flight_channels.contains(&ch), + q.in_flight_scopes.contains(&conv(ch)), "ch must still be in-flight after has_flushable_work finds ch2 work" ); } @@ -5140,15 +5487,15 @@ mod tests { let mut q = EventQueue::new(DedupMode::Queue); let ch = Uuid::new_v4(); - q.in_flight_channels.insert(ch); + q.in_flight_scopes.insert(conv(ch)); q.in_flight_deadlines - .insert(ch, Instant::now() + Duration::from_secs(100)); + .insert(conv(ch), Instant::now() + Duration::from_secs(100)); q.extend_in_flight_deadline(ch, 7200); - let after_first = *q.in_flight_deadlines.get(&ch).unwrap(); + let after_first = *q.in_flight_deadlines.get(&conv(ch)).unwrap(); q.extend_in_flight_deadline(ch, 7200); - let after_second = *q.in_flight_deadlines.get(&ch).unwrap(); + let after_second = *q.in_flight_deadlines.get(&conv(ch)).unwrap(); assert!( after_second >= after_first, @@ -5279,6 +5626,7 @@ mod tests { fn description_batch(ch: Uuid, event: Event) -> FlushBatch { FlushBatch { channel_id: ch, + scope: conv(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), From f6dec4d925926d655f9cf06160c29483a860f4ec Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Tue, 25 Aug 2026 12:15:57 -0400 Subject: [PATCH 3/9] feat(buzz-acp): partition provider-session state by SessionScope (step 3) Key the pool's provider-session state by SessionScope instead of only channel_id, so repeated activity in a thread reuses exactly that thread's provider session and unrelated threads in one channel never share session state, turn counters, context-delivery markers, or delivery-dedup state. - SessionState maps (sessions, turn_counts, core_sections, canvas_sections, deliveries) are now keyed by SessionScope. run_prompt_task resolves the session, core/canvas sections, standing-context-sent marker, turn count, and delivery ledger by the batch's scope; channel-level fetches (canvas, huddle, title, resolve) still use scope.channel_id(). - Scope-to-worker affinity: has_session_for/try_claim match the exact scope, so a temporarily busy worker cannot cause another worker to open a duplicate session for the same thread. TaskMeta carries the scope; send_steer and record_successful_steer route by it. - Channel-wide cleanup preserved: invalidate_channel clears every child thread scope for a channel (returns the count); invalidate_channel_sessions and the removed-channel path use it. Added invalidate_scope for single-session invalidation and mark_scope_delivery_success. - Model-switch targeting stays channel-level with an explicit TODO for channel-vs-thread control targeting (same open question as top-level !cancel / !rotate, deferred per the ticket). New pool tests: two threads in one channel get distinct sessions and reuse per root, invalidate_scope leaves a sibling thread untouched, and invalidate_channel clears every thread scope while sparing other channels. Full buzz-acp suite (822 lib + integration) green; clippy and fmt clean. Signed-off-by: Salman Mohammed --- crates/buzz-acp/src/lib.rs | 149 +++++++----- crates/buzz-acp/src/pool.rs | 458 ++++++++++++++++++++++-------------- 2 files changed, 373 insertions(+), 234 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 5d4ca1135a4..210cc4295ee 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -3356,11 +3356,7 @@ async fn tokio_main() -> Result<()> { ); if let Ok(pool::SteerAck::Success { session_id }) = &ack { queue.extend_in_flight_deadline(&scope, config.max_turn_duration_secs); - if !pool.record_successful_steer( - channel_id, - event_id.clone(), - session_id.clone(), - ) { + if !pool.record_successful_steer(&scope, event_id.clone(), session_id.clone()) { tracing::warn!( channel = %channel_id, event_id = %event_id, @@ -3700,7 +3696,7 @@ 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` @@ -3764,23 +3760,27 @@ fn dispatch_pending( None => break, }; let channel_id = batch.channel_id; + let scope = batch.scope.clone(); let typing_scope = batch .events .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)) { + // Scope-level affinity: reuse the worker that already holds THIS + // thread's provider session so a temporarily busy worker cannot cause + // another to open a duplicate session for the same thread. + let affinity_hit = pool.has_session_for(&scope); + let mut agent = match pool.try_claim(Some(&scope)) { Some(a) => a, None => { let pending = queue.pending_channels(); tracing::debug!(pending_channels = pending, "pool_exhausted"); queue.requeue_preserve_timestamps(batch); - queue.mark_complete(channel_id); + queue.mark_complete(&scope); break; } }; - tracing::debug!(agent = agent.index, channel = %channel_id, affinity_hit, "agent_claimed"); + tracing::debug!(agent = agent.index, channel = %channel_id, scope = %scope.telemetry_label(), affinity_hit, "agent_claimed"); let recoverable_batch = match ctx.dedup_mode { DedupMode::Queue => Some(batch.clone()), @@ -3828,6 +3828,7 @@ fn dispatch_pending( pool::TaskMeta { agent_index, channel_id: Some(channel_id), + scope: Some(scope), turn_id, recoverable_batch, control_tx: Some(control_tx), @@ -3923,19 +3924,20 @@ fn handle_prompt_result( pool.task_map_mut() .retain(|_, meta| meta.agent_index != agent_index); debug_assert_eq!(before, pool.task_map().len() + 1); - if let Some(channel_id) = result.source.channel_id() { + if let PromptSource::Channel(scope) = &result.source { // The task may have invalidated this session before returning. Never // resurrect delivery state for a dead session; its replacement must // receive fresh standing context and history. - if let Some(live_session_id) = result.agent.state.sessions.get(&channel_id).cloned() { + if let Some(live_session_id) = result.agent.state.sessions.get(scope).cloned() { let event_ids = successful_steer_deliveries .into_iter() .filter(|delivery| delivery.session_id == live_session_id) .map(|delivery| delivery.event_id); + let scope = scope.clone(); result .agent .state - .mark_channel_delivery_success(channel_id, false, event_ids); + .mark_scope_delivery_success(scope, false, event_ids); } } @@ -4461,6 +4463,7 @@ fn dispatch_heartbeat( pool::TaskMeta { agent_index, channel_id: None, + scope: None, turn_id, recoverable_batch: None, control_tx: None, @@ -5257,6 +5260,7 @@ mod owner_control_command_tests { pool::TaskMeta { agent_index: 0, channel_id: Some(channel_id), + scope: Some(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: Some(control_tx), @@ -7102,14 +7106,14 @@ mod error_outcome_emission_tests { let channel_id = Uuid::new_v4(); let steer_event_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; let mut agent = dummy_agent(0).await; - agent - .state - .sessions - .insert(channel_id, "live-session".into()); - agent - .state - .deliveries - .insert(channel_id, Default::default()); + agent.state.sessions.insert( + scope::SessionScope::Conversation { channel_id }, + "live-session".into(), + ); + agent.state.deliveries.insert( + scope::SessionScope::Conversation { channel_id }, + Default::default(), + ); let mut pool = AgentPool::from_slots(vec![None]); let task_id = pool.join_set.spawn(async {}).id(); @@ -7118,6 +7122,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: Some(channel_id), + scope: Some(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".into(), recoverable_batch: None, control_tx: None, @@ -7165,23 +7170,25 @@ mod error_outcome_emission_tests { ); let returned = pool.agents_mut()[0].as_ref().expect("returned agent"); - assert!(returned.state.deliveries[&channel_id] - .delivered_event_ids - .contains(steer_event_id)); + assert!( + returned.state.deliveries[&scope::SessionScope::Conversation { channel_id }] + .delivered_event_ids + .contains(steer_event_id) + ); } #[tokio::test] async fn in_flight_stale_native_steer_ack_cannot_update_replacement_session() { let channel_id = Uuid::new_v4(); let mut agent = dummy_agent(0).await; - agent - .state - .sessions - .insert(channel_id, "replacement-session".into()); - agent - .state - .deliveries - .insert(channel_id, Default::default()); + agent.state.sessions.insert( + scope::SessionScope::Conversation { channel_id }, + "replacement-session".into(), + ); + agent.state.deliveries.insert( + scope::SessionScope::Conversation { channel_id }, + Default::default(), + ); let mut pool = AgentPool::from_slots(vec![None]); let task_id = pool.join_set.spawn(async {}).id(); @@ -7190,6 +7197,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: Some(channel_id), + scope: Some(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".into(), recoverable_batch: None, control_tx: None, @@ -7237,9 +7245,11 @@ mod error_outcome_emission_tests { ); let returned = pool.agents_mut()[0].as_ref().expect("returned agent"); - assert!(returned.state.deliveries[&channel_id] - .delivered_event_ids - .is_empty()); + assert!( + returned.state.deliveries[&scope::SessionScope::Conversation { channel_id }] + .delivered_event_ids + .is_empty() + ); } #[tokio::test] @@ -7247,50 +7257,54 @@ mod error_outcome_emission_tests { let channel_id = Uuid::new_v4(); let steer_event_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; let mut agent = dummy_agent(0).await; - agent - .state - .sessions - .insert(channel_id, "live-session".into()); - agent - .state - .deliveries - .insert(channel_id, Default::default()); + agent.state.sessions.insert( + scope::SessionScope::Conversation { channel_id }, + "live-session".into(), + ); + agent.state.deliveries.insert( + scope::SessionScope::Conversation { channel_id }, + Default::default(), + ); let mut pool = AgentPool::from_slots(vec![Some(agent)]); assert!(pool.record_successful_steer( - channel_id, + &scope::SessionScope::Conversation { channel_id }, steer_event_id.into(), "live-session".into(), )); let returned = pool.agents_mut()[0].as_ref().expect("idle returned agent"); - assert!(returned.state.deliveries[&channel_id] - .delivered_event_ids - .contains(steer_event_id)); + assert!( + returned.state.deliveries[&scope::SessionScope::Conversation { channel_id }] + .delivered_event_ids + .contains(steer_event_id) + ); } #[tokio::test] async fn late_native_steer_ack_cannot_update_replacement_session() { let channel_id = Uuid::new_v4(); let mut agent = dummy_agent(0).await; - agent - .state - .sessions - .insert(channel_id, "replacement-session".into()); - agent - .state - .deliveries - .insert(channel_id, Default::default()); + agent.state.sessions.insert( + scope::SessionScope::Conversation { channel_id }, + "replacement-session".into(), + ); + agent.state.deliveries.insert( + scope::SessionScope::Conversation { channel_id }, + Default::default(), + ); let mut pool = AgentPool::from_slots(vec![Some(agent)]); assert!(!pool.record_successful_steer( - channel_id, + &scope::SessionScope::Conversation { channel_id }, "stale-event".into(), "old-session".into(), )); let returned = pool.agents_mut()[0].as_ref().expect("replacement agent"); - assert!(returned.state.deliveries[&channel_id] - .delivered_event_ids - .is_empty()); + assert!( + returned.state.deliveries[&scope::SessionScope::Conversation { channel_id }] + .delivered_event_ids + .is_empty() + ); } #[tokio::test] @@ -7305,6 +7319,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: Some(channel_id), + scope: Some(scope::SessionScope::Conversation { channel_id }), turn_id: "test-turn-id".into(), recoverable_batch: None, control_tx: None, @@ -7351,7 +7366,10 @@ mod error_outcome_emission_tests { ); let returned = pool.agents_mut()[0].as_ref().expect("returned agent"); - assert!(!returned.state.deliveries.contains_key(&channel_id)); + assert!(!returned + .state + .deliveries + .contains_key(&scope::SessionScope::Conversation { channel_id })); } /// Drive one error outcome through `handle_prompt_result` and return how @@ -7370,6 +7388,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -7449,6 +7468,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: Some(channel_id), + scope: Some(scope::SessionScope::Conversation { channel_id }), turn_id: "panic-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -7542,6 +7562,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -7638,6 +7659,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -7745,6 +7767,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -7822,6 +7845,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -7918,6 +7942,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8037,6 +8062,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8178,6 +8204,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8370,6 +8397,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -8457,6 +8485,7 @@ mod error_outcome_emission_tests { crate::pool::TaskMeta { agent_index: 0, channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 34e93fcc81e..0942cd3cc32 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -60,6 +60,10 @@ pub struct SuccessfulSteerDelivery { pub struct TaskMeta { pub agent_index: usize, pub channel_id: Option, + /// Session scope of the in-flight turn (mid-turn steer/signal routing and + /// scope-to-worker affinity target this). `None` for heartbeat tasks. + /// Invariant when `Some`: `scope.channel_id() == channel_id.unwrap()`. + 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. @@ -113,29 +117,29 @@ pub struct ChannelDeliveryState { /// spawning a real agent subprocess. #[derive(Default)] pub struct SessionState { - /// channel_id → session_id - pub sessions: HashMap, + /// session scope → session_id + pub sessions: HashMap, pub heartbeat_session: Option, - /// Per-channel turn counters for proactive session rotation. + /// Per-scope 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, /// Whether the live heartbeat session has successfully received `[Base]`. pub heartbeat_standing_context_sent: bool, - /// channel_id → rendered NIP-AE core prompt section, populated once at + /// session scope → rendered NIP-AE core prompt section, populated once at /// session creation per Tyler's spec (no mid-session refresh). - pub core_sections: HashMap, - /// channel_id → rendered `[Channel Canvas]` metadata section. + pub core_sections: HashMap, + /// session scope → rendered `[Channel Canvas]` metadata section. /// /// Populated once before session creation (same lifecycle as `core_sections`). /// Absent when the channel has no canvas, the canvas content is blank, or the /// fetch fails — all fail open. Cleared on session invalidation alongside /// `core_sections` so the next session picks up any canvas change. - pub canvas_sections: HashMap, - /// Per-channel successful-delivery state. Created with the ACP session and + pub canvas_sections: HashMap, + /// Per-scope successful-delivery state. Created with the ACP session and /// cleared atomically with every invalidation path. - pub deliveries: HashMap, + pub deliveries: HashMap, } impl SessionState { @@ -143,7 +147,7 @@ impl SessionState { pub fn invalidate(&mut self, source: &PromptSource) { match source { PromptSource::Channel(scope) => { - self.invalidate_channel(&scope.channel_id()); + self.invalidate_scope(scope); } PromptSource::Heartbeat => { self.heartbeat_session = None; @@ -153,14 +157,39 @@ impl SessionState { } } - /// Invalidate a single channel's session and turn counter. - /// Returns `true` if the channel had an active session. - pub fn invalidate_channel(&mut self, channel_id: &Uuid) -> bool { - self.turn_counts.remove(channel_id); - self.core_sections.remove(channel_id); - self.canvas_sections.remove(channel_id); - self.deliveries.remove(channel_id); - self.sessions.remove(channel_id).is_some() + /// Invalidate a single session scope's session and turn counter. + /// Returns `true` if the scope had an active session. + pub fn invalidate_scope(&mut self, scope: &SessionScope) -> bool { + self.turn_counts.remove(scope); + self.core_sections.remove(scope); + self.canvas_sections.remove(scope); + self.deliveries.remove(scope); + self.sessions.remove(scope).is_some() + } + + /// Invalidate every session scope belonging to `channel_id` (channel-wide + /// cleanup, e.g. when the agent is removed from a channel). Returns the + /// number of scopes that had an active session. + pub fn invalidate_channel(&mut self, channel_id: &Uuid) -> usize { + let scopes: Vec = self + .sessions + .keys() + .chain(self.turn_counts.keys()) + .chain(self.core_sections.keys()) + .chain(self.canvas_sections.keys()) + .chain(self.deliveries.keys()) + .filter(|s| s.channel_id() == *channel_id) + .cloned() + .collect::>() + .into_iter() + .collect(); + let mut count = 0; + for scope in scopes { + if self.invalidate_scope(&scope) { + count += 1; + } + } + count } /// Invalidate all sessions and turn counters (e.g. after agent exit). @@ -175,24 +204,25 @@ impl SessionState { self.deliveries.clear(); } - pub(crate) fn mark_channel_delivery_success( + pub(crate) fn mark_scope_delivery_success( &mut self, - channel_id: Uuid, + scope: SessionScope, standing_context_sent: bool, event_ids: impl IntoIterator, ) { - let delivery = self.deliveries.entry(channel_id).or_default(); + let delivery = self.deliveries.entry(scope).or_default(); delivery.standing_context_sent |= standing_context_sent; delivery.delivered_event_ids.extend(event_ids); } #[cfg(test)] fn has_channel_state(&self, channel_id: &Uuid) -> bool { - self.sessions.contains_key(channel_id) - || self.turn_counts.contains_key(channel_id) - || self.core_sections.contains_key(channel_id) - || self.canvas_sections.contains_key(channel_id) - || self.deliveries.contains_key(channel_id) + let matches = |s: &SessionScope| s.channel_id() == *channel_id; + self.sessions.keys().any(matches) + || self.turn_counts.keys().any(matches) + || self.core_sections.keys().any(matches) + || self.canvas_sections.keys().any(matches) + || self.deliveries.keys().any(matches) } } @@ -677,18 +707,21 @@ 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 session 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 this exact scope + /// (thread affinity — repeated activity in a thread reuses that thread's + /// provider session). /// 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, scope: Option<&SessionScope>) -> Option { + // Pass 1: prefer agent with existing session for this scope. + if let Some(scope) = scope { let idx = self.agents.iter().position(|slot| { slot.as_ref() - .map(|a| a.state.sessions.contains_key(&cid)) + .map(|a| a.state.sessions.contains_key(scope)) .unwrap_or(false) }); if let Some(i) = idx { @@ -722,12 +755,12 @@ impl AgentPool { self.agents.iter().any(|slot| slot.is_some()) } - /// Whether any idle agent already has a session for `channel_id`. + /// Whether any idle agent already has a session for `scope`. /// 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, scope: &SessionScope) -> bool { self.agents.iter().any(|slot| { slot.as_ref() - .map(|a| a.state.sessions.contains_key(&channel_id)) + .map(|a| a.state.sessions.contains_key(scope)) .unwrap_or(false) }) } @@ -773,13 +806,13 @@ impl AgentPool { /// event and let normal dispatch handle delivery. pub fn send_steer( &mut self, - channel_id: Uuid, + scope: &SessionScope, 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 @@ -795,14 +828,14 @@ impl AgentPool { /// we write directly to the idle agent's matching live-session ledger. pub fn record_successful_steer( &mut self, - channel_id: Uuid, + scope: &SessionScope, event_id: String, session_id: String, ) -> bool { if let Some(meta) = self .task_map .values_mut() - .find(|meta| meta.channel_id == Some(channel_id)) + .find(|meta| meta.scope.as_ref() == Some(scope)) { meta.successful_steer_deliveries .insert(SuccessfulSteerDelivery { @@ -813,13 +846,13 @@ impl AgentPool { } let Some(agent) = self.agents.iter_mut().flatten().find(|agent| { - agent.state.sessions.get(&channel_id).map(String::as_str) == Some(session_id.as_str()) + agent.state.sessions.get(scope).map(String::as_str) == Some(session_id.as_str()) }) else { return false; }; agent .state - .mark_channel_delivery_success(channel_id, false, [event_id]); + .mark_scope_delivery_success(scope.clone(), false, [event_id]); true } @@ -870,9 +903,8 @@ impl AgentPool { let mut count = 0; for slot in &mut self.agents { if let Some(agent) = slot.as_mut() { - if agent.state.invalidate_channel(&channel_id) { - count += 1; - } + // Channel-wide: clears every child thread scope for the channel. + count += agent.state.invalidate_channel(&channel_id); } } count @@ -897,12 +929,17 @@ impl AgentPool { model_id: &str, request_id: Option, ) -> IdleSwitchResult { - let Some(agent) = self - .agents - .iter_mut() - .flatten() - .find(|a| a.state.sessions.contains_key(&channel_id)) - else { + // TODO(thread-scope): model switch is still channel-level targeted — + // with multiple thread sessions in a channel this picks the first idle + // agent holding ANY session for the channel. Channel-vs-thread control + // targeting is deferred (same open question as top-level !cancel / + // !rotate). + let Some(agent) = self.agents.iter_mut().flatten().find(|a| { + a.state + .sessions + .keys() + .any(|s| s.channel_id() == channel_id) + }) else { return IdleSwitchResult::NoIdleAgent; }; @@ -1871,11 +1908,12 @@ pub async fn run_prompt_task( if let (PromptSource::Channel(scope), Some(owner_pk)) = (&source, ctx.agent_owner_pubkey.as_ref()) { - // Pool session state is still channel-keyed in this commit (Step 3 - // rekeys it by scope); index by the scope's channel for now. + // Session state is keyed by scope: repeated activity in a thread + // reuses exactly that thread's session. `cid` is only for + // channel-level fetches/logging. let cid = &scope.channel_id(); - let is_new_channel_session = !agent.state.sessions.contains_key(cid); - if is_new_channel_session && !agent.state.core_sections.contains_key(cid) { + let is_new_channel_session = !agent.state.sessions.contains_key(scope); + if is_new_channel_session && !agent.state.core_sections.contains_key(scope) { // Bounded — we'd rather start the session with no core hint // than block session creation on a stalled relay. const CORE_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); @@ -1900,10 +1938,11 @@ pub async fn run_prompt_task( tracing::info!( target: "engram::core", channel = %cid, + scope = %scope.telemetry_label(), section_len = rendered.len(), "injected NIP-AE core section into system prompt" ); - agent.state.core_sections.insert(*cid, rendered); + agent.state.core_sections.insert(scope.clone(), rendered); } } } @@ -1921,30 +1960,30 @@ pub async fn run_prompt_task( // commit it to `canvas_sections` only after session creation succeeds. This // 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; + let mut pending_canvas: Option<(SessionScope, String)> = None; let mut huddle_instructions: Option = None; // Channel name for the session title, from the same single resolve the // canvas DM check uses — see `resolve_new_session_channel_context`. let mut title_channel: Option = None; let mut origin_channel_type: Option = None; if let PromptSource::Channel(scope) = &source { - let cid = &scope.channel_id(); - let is_new_channel_session = !agent.state.sessions.contains_key(cid); - let needs_canvas = is_new_channel_session && !agent.state.canvas_sections.contains_key(cid); + let cid = scope.channel_id(); + let is_new_channel_session = !agent.state.sessions.contains_key(scope); + let needs_canvas = + is_new_channel_session && !agent.state.canvas_sections.contains_key(scope); if is_new_channel_session { let (is_dm, resolved_channel, resolved_channel_type) = - resolve_new_session_channel_context(&ctx.channel_info, *cid).await; + resolve_new_session_channel_context(&ctx.channel_info, cid).await; title_channel = resolved_channel; origin_channel_type = resolved_channel_type; if let Some(owner) = ctx.agent_owner_pubkey.as_ref() { - huddle_instructions = - fetch_huddle_instructions(*cid, owner, &ctx.rest_client).await; + huddle_instructions = fetch_huddle_instructions(cid, owner, &ctx.rest_client).await; } // A confirmed DM never receives a canvas section; an undeterminable // channel type fails closed as a DM for the same reason. if needs_canvas && !is_dm { - if let Some(section) = fetch_canvas_section(*cid, &ctx.rest_client).await { - pending_canvas = Some((*cid, section)); + if let Some(section) = fetch_canvas_section(cid, &ctx.rest_client).await { + pending_canvas = Some((scope.clone(), section)); } } } @@ -1953,7 +1992,7 @@ 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(scope) => agent.state.core_sections.get(&scope.channel_id()).cloned(), + PromptSource::Channel(scope) => agent.state.core_sections.get(scope).cloned(), PromptSource::Heartbeat => None, }; @@ -1963,7 +2002,7 @@ pub async fn run_prompt_task( PromptSource::Channel(scope) => agent .state .canvas_sections - .get(&scope.channel_id()) + .get(scope) .cloned() .or_else(|| pending_canvas.as_ref().map(|(_, s)| s.clone())), PromptSource::Heartbeat => None, @@ -1972,7 +2011,7 @@ pub async fn run_prompt_task( let (session_id, is_new_session) = match &source { PromptSource::Channel(scope) => { let cid = &scope.channel_id(); - if let Some(sid) = agent.state.sessions.get(cid) { + if let Some(sid) = agent.state.sessions.get(scope) { (sid.clone(), false) } else { // The title is channel-qualified (`Agent · #channel`) so one @@ -1996,19 +2035,20 @@ pub async fn run_prompt_task( Ok(sid) => { tracing::info!( target: "pool::session", - "created session {sid} for channel {cid}" + "created session {sid} for channel {cid} (scope {})", + scope.telemetry_label() ); - agent.state.sessions.insert(*cid, sid.clone()); + agent.state.sessions.insert(scope.clone(), sid.clone()); agent .state .deliveries - .insert(*cid, ChannelDeliveryState::default()); + .insert(scope.clone(), ChannelDeliveryState::default()); // Seed a zero usage baseline: buzz-acp spawned this session // so prior usage is zero by definition — first turn is reliable. agent.acp.notify_session_spawned(&sid); // 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); + if let Some((pending_scope, section)) = pending_canvas.take() { + agent.state.canvas_sections.insert(pending_scope, section); } (sid, true) } @@ -2135,7 +2175,7 @@ pub async fn run_prompt_task( PromptSource::Channel(scope) => agent .state .deliveries - .get(&scope.channel_id()) + .get(scope) .is_some_and(|delivery| delivery.standing_context_sent), PromptSource::Heartbeat => agent.state.heartbeat_standing_context_sent, }; @@ -2178,7 +2218,9 @@ pub async fn run_prompt_task( // prompt below must not repeat it. Every other arm returns. standing_context_sent = true; if !agent.has_system_prompt_support() { - agent.state.mark_channel_delivery_success(*cid, true, []); + agent + .state + .mark_scope_delivery_success(scope.clone(), true, []); } let usage = agent.acp.take_turn_usage(); publish_agent_turn_metric( @@ -2346,7 +2388,7 @@ pub async fn run_prompt_task( let delivered_ids = agent .state .deliveries - .get(&b.channel_id) + .get(&b.scope) .map(|delivery| &delivery.delivered_event_ids) .cloned() .unwrap_or_default(); @@ -2617,9 +2659,9 @@ pub async fn run_prompt_task( log_stop_reason(&source, &StopReason::EndTurn); if let PromptSource::Channel(scope) = &source { let standing_sent = !agent.has_system_prompt_support(); - record_channel_delivery_success( + record_scope_delivery_success( &mut agent, - scope.channel_id(), + scope.clone(), standing_sent, &pending_delivered_event_ids, ); @@ -2660,9 +2702,9 @@ pub async fn run_prompt_task( if let PromptSource::Channel(scope) = &source { let standing_sent = !agent.has_system_prompt_support(); - record_channel_delivery_success( + record_scope_delivery_success( &mut agent, - scope.channel_id(), + scope.clone(), standing_sent, &pending_delivered_event_ids, ); @@ -2680,11 +2722,7 @@ pub async fn run_prompt_task( if limit > 0 { match &source { PromptSource::Channel(scope) => { - let count = agent - .state - .turn_counts - .entry(scope.channel_id()) - .or_insert(0); + let count = agent.state.turn_counts.entry(scope.clone()).or_insert(0); *count += 1; *count >= limit } @@ -4100,19 +4138,19 @@ fn delivery_receipt_line(channel_id: Uuid, event_ids: &HashSet) -> Strin ) } -fn record_channel_delivery_success( +fn record_scope_delivery_success( agent: &mut OwnedAgent, - channel_id: Uuid, + scope: SessionScope, standing_context_sent: bool, event_ids: &HashSet, ) { tracing::info!( target: "pool::prompt", "{}", - delivery_receipt_line(channel_id, event_ids) + delivery_receipt_line(scope.channel_id(), event_ids) ); - agent.state.mark_channel_delivery_success( - channel_id, + agent.state.mark_scope_delivery_success( + scope, standing_context_sent, event_ids.iter().cloned(), ); @@ -4733,6 +4771,12 @@ mod tests { use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use serde_json::json; + /// Conversation scope for a channel — the scope these pool tests exercise + /// (equivalent to the pre-thread-scoping channel key). + fn conv(channel_id: Uuid) -> SessionScope { + SessionScope::Conversation { channel_id } + } + fn test_mcp_server() -> McpServer { McpServer { name: "dev".into(), @@ -6114,11 +6158,11 @@ done"# agent .state .sessions - .insert(channel_id, "live-session".into()); + .insert(conv(channel_id), "live-session".into()); agent .state .deliveries - .insert(channel_id, ChannelDeliveryState::default()); + .insert(conv(channel_id), ChannelDeliveryState::default()); let mut ctx = make_prompt_context_no_owner(); ctx.base_prompt = Some("standing-once"); @@ -6159,7 +6203,7 @@ done"# PromptOutcome::Ok(StopReason::EndTurn) )), } - let delivery = &result.agent.state.deliveries[&channel_id]; + let delivery = &result.agent.state.deliveries[&conv(channel_id)]; assert_eq!( delivery.standing_context_sent, turn >= 2, @@ -6292,11 +6336,11 @@ done"# agent .state .sessions - .insert(channel_id, "live-session".into()); + .insert(conv(channel_id), "live-session".into()); agent .state .deliveries - .insert(channel_id, ChannelDeliveryState::default()); + .insert(conv(channel_id), ChannelDeliveryState::default()); let mut ctx = make_prompt_context_no_owner(); ctx.context_message_limit = 10; @@ -6338,7 +6382,7 @@ done"# )); agent = result.agent; } - let delivery = &agent.state.deliveries[&channel_id]; + let delivery = &agent.state.deliveries[&conv(channel_id)]; assert!(delivery.delivered_event_ids.contains(&carry_over_id)); assert!(delivery.delivered_event_ids.contains(&new_event_id)); agent.acp.shutdown().await; @@ -6446,22 +6490,22 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" agent .state .sessions - .insert(channel_id, "live-session".into()); + .insert(conv(channel_id), "live-session".into()); agent .state .deliveries - .insert(channel_id, ChannelDeliveryState::default()); + .insert(conv(channel_id), ChannelDeliveryState::default()); // Model the adversarial ordering: the task result has already retired // its TaskMeta and returned the agent before the successful ack arrives. let mut pool = AgentPool::from_slots(vec![Some(agent)]); assert!(pool.record_successful_steer( - channel_id, + &conv(channel_id), steered_event_id.clone(), "live-session".into(), )); let agent = pool - .try_claim(Some(channel_id)) + .try_claim(Some(&conv(channel_id))) .expect("claim returned agent"); let mut ctx = make_prompt_context_no_owner(); @@ -6524,19 +6568,19 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" let mut state = SessionState::default(); state .deliveries - .insert(channel, ChannelDeliveryState::default()); + .insert(conv(channel), ChannelDeliveryState::default()); // Building or attempting a prompt does not mutate delivery state. - let delivery = state.deliveries.get(&channel).unwrap(); + let delivery = state.deliveries.get(&conv(channel)).unwrap(); assert!(!delivery.standing_context_sent); assert!(delivery.delivered_event_ids.is_empty()); - state.mark_channel_delivery_success( - channel, + state.mark_scope_delivery_success( + conv(channel), true, ["trigger".to_string(), "context".to_string()], ); - let delivery = state.deliveries.get(&channel).unwrap(); + let delivery = state.deliveries.get(&conv(channel)).unwrap(); assert!(delivery.standing_context_sent); assert_eq!(delivery.delivered_event_ids.len(), 2); } @@ -6545,17 +6589,17 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" fn delivery_state_is_cleared_on_rotation_and_restarts_empty() { let channel = Uuid::new_v4(); let mut state = SessionState::default(); - state.sessions.insert(channel, "old-session".into()); - state.mark_channel_delivery_success(channel, true, ["old-event".to_string()]); + state.sessions.insert(conv(channel), "old-session".into()); + state.mark_scope_delivery_success(conv(channel), true, ["old-event".to_string()]); - assert!(state.invalidate_channel(&channel)); - assert!(!state.deliveries.contains_key(&channel)); + assert!(state.invalidate_channel(&channel) > 0); + assert!(!state.deliveries.contains_key(&conv(channel))); - state.sessions.insert(channel, "new-session".into()); + state.sessions.insert(conv(channel), "new-session".into()); state .deliveries - .insert(channel, ChannelDeliveryState::default()); - let delivery = state.deliveries.get(&channel).unwrap(); + .insert(conv(channel), ChannelDeliveryState::default()); + let delivery = state.deliveries.get(&conv(channel)).unwrap(); assert!(!delivery.standing_context_sent); assert!(delivery.delivered_event_ids.is_empty()); } @@ -6662,21 +6706,21 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" 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.core_sections.insert(ch_a, "core-a".into()); - s.core_sections.insert(ch_b, "core-b".into()); + s.sessions.insert(conv(ch_a), "sess-a".into()); + s.sessions.insert(conv(ch_b), "sess-b".into()); + s.turn_counts.insert(conv(ch_a), 5); + s.turn_counts.insert(conv(ch_b), 3); + s.core_sections.insert(conv(ch_a), "core-a".into()); + s.core_sections.insert(conv(ch_b), "core-b".into()); s.deliveries.insert( - ch_a, + conv(ch_a), ChannelDeliveryState { standing_context_sent: true, delivered_event_ids: HashSet::from(["event-a".into()]), }, ); s.deliveries.insert( - ch_b, + conv(ch_b), ChannelDeliveryState { standing_context_sent: true, delivered_event_ids: HashSet::from(["event-b".into()]), @@ -6688,6 +6732,72 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" (s, ch_a, ch_b) } + fn thread_scope(channel_id: Uuid, root: &str) -> SessionScope { + SessionScope::Thread { + channel_id, + root_event_id: root.to_string(), + } + } + + #[test] + fn two_threads_in_one_channel_get_distinct_sessions() { + let ch = Uuid::new_v4(); + let ta = thread_scope(ch, &"a".repeat(64)); + let tb = thread_scope(ch, &"b".repeat(64)); + let mut s = SessionState::default(); + s.sessions.insert(ta.clone(), "sess-thread-a".into()); + s.sessions.insert(tb.clone(), "sess-thread-b".into()); + // Distinct roots key distinct provider sessions. + assert_eq!( + s.sessions.get(&ta).map(String::as_str), + Some("sess-thread-a") + ); + assert_eq!( + s.sessions.get(&tb).map(String::as_str), + Some("sess-thread-b") + ); + // Repeated activity under one root reuses that exact session. + assert_eq!( + s.sessions.get(&ta).map(String::as_str), + Some("sess-thread-a") + ); + // The conversation scope is a different key again (no accidental reuse). + assert!(!s.sessions.contains_key(&conv(ch))); + } + + #[test] + fn invalidate_scope_leaves_sibling_thread_untouched() { + let ch = Uuid::new_v4(); + let ta = thread_scope(ch, &"a".repeat(64)); + let tb = thread_scope(ch, &"b".repeat(64)); + let mut s = SessionState::default(); + s.sessions.insert(ta.clone(), "a".into()); + s.sessions.insert(tb.clone(), "b".into()); + s.turn_counts.insert(ta.clone(), 2); + assert!(s.invalidate_scope(&ta)); + assert!(!s.sessions.contains_key(&ta)); + assert!(!s.turn_counts.contains_key(&ta)); + // Sibling thread's session survives. + assert_eq!(s.sessions.get(&tb).map(String::as_str), Some("b")); + } + + #[test] + fn invalidate_channel_clears_every_thread_scope() { + let ch = Uuid::new_v4(); + let other = Uuid::new_v4(); + let mut s = SessionState::default(); + s.sessions + .insert(thread_scope(ch, &"a".repeat(64)), "a".into()); + s.sessions + .insert(thread_scope(ch, &"b".repeat(64)), "b".into()); + s.sessions.insert(conv(ch), "c".into()); + s.sessions + .insert(thread_scope(other, &"d".repeat(64)), "d".into()); + let cleared = s.invalidate_channel(&ch); + assert_eq!(cleared, 3, "all three ch scopes had sessions"); + assert!(s.sessions.keys().all(|k| k.channel_id() == other)); + } + #[test] fn test_rotate_after_natural_completion_invalidates_channel_state() { let (mut s, ch_a, ch_b) = make_state(); @@ -6698,13 +6808,13 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" &ControlSignal::Rotate, ); - assert!(!s.sessions.contains_key(&ch_a)); - assert!(!s.turn_counts.contains_key(&ch_a)); - assert!(!s.core_sections.contains_key(&ch_a)); + assert!(!s.sessions.contains_key(&conv(ch_a))); + assert!(!s.turn_counts.contains_key(&conv(ch_a))); + assert!(!s.core_sections.contains_key(&conv(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.core_sections.get(&ch_b).unwrap(), "core-b"); + assert_eq!(s.sessions.get(&conv(ch_b)).unwrap(), "sess-b"); + assert_eq!(*s.turn_counts.get(&conv(ch_b)).unwrap(), 3); + assert_eq!(s.core_sections.get(&conv(ch_b)).unwrap(), "core-b"); assert_eq!(s.heartbeat_session.as_deref(), Some("sess-hb")); assert_eq!(s.heartbeat_turn_count, 7); } @@ -6719,10 +6829,10 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" &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.core_sections.get(&ch_a).unwrap(), "core-a"); - assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b"); + assert_eq!(s.sessions.get(&conv(ch_a)).unwrap(), "sess-a"); + assert_eq!(*s.turn_counts.get(&conv(ch_a)).unwrap(), 5); + assert_eq!(s.core_sections.get(&conv(ch_a)).unwrap(), "core-a"); + assert_eq!(s.sessions.get(&conv(ch_b)).unwrap(), "sess-b"); } #[test] @@ -6732,14 +6842,14 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" channel_id: ch_a, })); - assert!(!s.sessions.contains_key(&ch_a)); - assert!(!s.turn_counts.contains_key(&ch_a)); - assert!(!s.core_sections.contains_key(&ch_a)); + assert!(!s.sessions.contains_key(&conv(ch_a))); + assert!(!s.turn_counts.contains_key(&conv(ch_a))); + assert!(!s.core_sections.contains_key(&conv(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.core_sections.get(&ch_b).unwrap(), "core-b"); + assert_eq!(s.sessions.get(&conv(ch_b)).unwrap(), "sess-b"); + assert_eq!(*s.turn_counts.get(&conv(ch_b)).unwrap(), 3); + assert_eq!(s.core_sections.get(&conv(ch_b)).unwrap(), "core-b"); // heartbeat untouched assert_eq!(s.heartbeat_session.as_deref(), Some("sess-hb")); assert_eq!(s.heartbeat_turn_count, 7); @@ -6755,10 +6865,10 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" assert!(!s.heartbeat_standing_context_sent); // 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.core_sections.get(&ch_a).unwrap(), "core-a"); - assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b"); + assert_eq!(*s.turn_counts.get(&conv(ch_a)).unwrap(), 5); + assert_eq!(*s.turn_counts.get(&conv(ch_b)).unwrap(), 3); + assert_eq!(s.core_sections.get(&conv(ch_a)).unwrap(), "core-a"); + assert_eq!(s.core_sections.get(&conv(ch_b)).unwrap(), "core-b"); } #[test] @@ -6785,10 +6895,10 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" // 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.core_sections.get(&ch_a).unwrap(), "core-a"); - assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b"); + assert_eq!(*s.turn_counts.get(&conv(ch_a)).unwrap(), 5); + assert_eq!(*s.turn_counts.get(&conv(ch_b)).unwrap(), 3); + assert_eq!(s.core_sections.get(&conv(ch_a)).unwrap(), "core-a"); + assert_eq!(s.core_sections.get(&conv(ch_b)).unwrap(), "core-b"); } #[test] @@ -6803,15 +6913,15 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" #[test] 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.core_sections.contains_key(&ch_a)); + assert!(s.invalidate_channel(&ch_a) > 0); + assert!(!s.sessions.contains_key(&conv(ch_a))); + assert!(!s.turn_counts.contains_key(&conv(ch_a))); + assert!(!s.core_sections.contains_key(&conv(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.core_sections.get(&ch_b).unwrap(), "core-b"); + assert_eq!(s.sessions.get(&conv(ch_b)).unwrap(), "sess-b"); + assert_eq!(*s.turn_counts.get(&conv(ch_b)).unwrap(), 3); + assert_eq!(s.core_sections.get(&conv(ch_b)).unwrap(), "core-b"); // heartbeat untouched assert_eq!(s.heartbeat_session.as_deref(), Some("sess-hb")); assert_eq!(s.heartbeat_turn_count, 7); @@ -6821,7 +6931,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" fn test_invalidate_channel_returns_false_when_no_session() { let (mut s, _ch_a, _ch_b) = make_state(); let ghost = Uuid::new_v4(); - assert!(!s.invalidate_channel(&ghost)); + assert_eq!(s.invalidate_channel(&ghost), 0); // Nothing changed. assert_eq!(s.sessions.len(), 2); assert_eq!(s.turn_counts.len(), 2); @@ -6836,13 +6946,13 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" 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.core_sections.contains_key(&ch_a)); + assert!(!s.sessions.contains_key(&conv(ch_a))); + assert!(!s.turn_counts.contains_key(&conv(ch_a))); + assert!(!s.core_sections.contains_key(&conv(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.core_sections.get(&ch_b).unwrap(), "core-b"); + assert_eq!(s.sessions.get(&conv(ch_b)).unwrap(), "sess-b"); + assert_eq!(*s.turn_counts.get(&conv(ch_b)).unwrap(), 3); + assert_eq!(s.core_sections.get(&conv(ch_b)).unwrap(), "core-b"); } // ── ControlSignal::SwitchModel (Phase 3a, Option ii) ───────────────────── @@ -6864,8 +6974,8 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" 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(&conv(ch_b)).unwrap(), "sess-b"); + assert_eq!(*s.turn_counts.get(&conv(ch_b)).unwrap(), 3); } // ── requeue_cancelled_batch ──────────────────────────────────────────── @@ -8097,14 +8207,14 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" 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(conv(ch), "sess".into()); s.canvas_sections - .insert(ch, "[Channel Canvas]\nrev abc".into()); + .insert(conv(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.canvas_sections.contains_key(&conv(ch))); + assert!(!s.sessions.contains_key(&conv(ch))); } #[test] @@ -8112,9 +8222,9 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" let ch_a = Uuid::new_v4(); let ch_b = Uuid::new_v4(); 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.canvas_sections.insert(conv(ch_a), "canvas-a".into()); + s.canvas_sections.insert(conv(ch_b), "canvas-b".into()); + s.sessions.insert(conv(ch_a), "sess-a".into()); s.invalidate_all(); @@ -8127,22 +8237,22 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" 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.canvas_sections.insert(ch_a, "canvas-a".into()); - s.canvas_sections.insert(ch_b, "canvas-b".into()); + s.sessions.insert(conv(ch_a), "sess-a".into()); + s.sessions.insert(conv(ch_b), "sess-b".into()); + s.canvas_sections.insert(conv(ch_a), "canvas-a".into()); + s.canvas_sections.insert(conv(ch_b), "canvas-b".into()); s.invalidate_channel(&ch_a); - assert!(!s.canvas_sections.contains_key(&ch_a)); - assert_eq!(s.canvas_sections.get(&ch_b).unwrap(), "canvas-b"); + assert!(!s.canvas_sections.contains_key(&conv(ch_a))); + assert_eq!(s.canvas_sections.get(&conv(ch_b)).unwrap(), "canvas-b"); } #[test] fn test_has_channel_state_true_when_only_canvas_section_present() { let ch = Uuid::new_v4(); let mut s = SessionState::default(); - s.canvas_sections.insert(ch, "canvas".into()); + s.canvas_sections.insert(conv(ch), "canvas".into()); assert!(s.has_channel_state(&ch)); } From 73e87eaa55c267d1cb6793c8db1eba0a001f911b Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Tue, 25 Aug 2026 12:20:09 -0400 Subject: [PATCH 4/9] feat(buzz-acp): gather scope-correct context (step 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive conversation-context gathering from the batch's resolved SessionScope instead of re-inferring it from whichever event is last in the batch. - A Thread scope fetches only that canonical thread's history (all messages under the root, including intervening non-mention human messages), so no unrelated channel transcript is injected. A brand-new thread's first turn has no prior history — the trigger is delivered as the [Event] block. - Conversation scope preserves current behavior: DMs (and legacy channel-policy channels) fetch the reply chain for a threaded reply or recent DM history for a non-reply; a plain top-level channel message gets no supplementary context. - The existing scope-keyed delivery-delta filter (step 3) then strips events this session already received, so subsequent turns deliver only the intervening same-thread messages plus the trigger, without duplication. The routing decision is extracted into a pure `resolve_context_target` so it is unit-tested directly: thread scope wins over a divergent last-event tag, a new top-level thread resolves to its own root, a plain conversation-scope channel message gets no context, DM non-reply fetches DM history, and a conversation-scope reply uses its reply chain. Together with steps 1–3 this makes BUZZ_ACP_SESSION_POLICY=thread deliver real end-to-end isolation: distinct roots in one channel get distinct queue partitions (step 2), distinct provider sessions with scope-keyed worker affinity (step 3), and distinct canonical-thread context (this step); while the default `channel` policy is byte-identical to prior behavior. Full buzz-acp suite (827 lib + integration) green; clippy and fmt clean. Signed-off-by: Salman Mohammed --- crates/buzz-acp/src/pool.rs | 171 +++++++++++++++++++++++++++++++----- 1 file changed, 151 insertions(+), 20 deletions(-) diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 0942cd3cc32..d71c2ed6885 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -3343,8 +3343,21 @@ fn conversation_context_delta( /// - The REST fetch fails or times out (graceful degradation) /// - `context_message_limit` is 0 /// -/// For batches with multiple events, thread context is fetched for the **last** -/// reply event only (most recent = most likely to need a response). +/// Context is scoped by the batch's resolved [`SessionScope`], never inferred +/// from whichever event happens to be last: +/// +/// - **Thread scope** → fetch only that canonical thread's history (all +/// messages under the root, including intervening non-mention human +/// messages). A brand-new thread (root == the triggering event, first turn) +/// has no prior history, so this returns `None`, which is correct: the +/// trigger itself is delivered as the `[Event]` block. +/// - **Conversation scope** (DMs always; channels under the `channel` policy) +/// → preserve legacy behavior: a threaded reply fetches its reply chain; +/// a DM non-reply fetches recent conversation history. +/// +/// The delivery-delta filter (`conversation_context_delta`) then removes any +/// events this scope's live session already received, so subsequent turns +/// deliver only intervening same-thread messages plus the trigger. async fn fetch_conversation_context( batch: &FlushBatch, channel_info: &Option, @@ -3356,28 +3369,54 @@ async fn fetch_conversation_context( .map(|ci| ci.channel_type == "dm") .unwrap_or(false); - // Check thread tags on the last event first — this applies to both - // channels and DMs. A DM reply needs thread context (not channel history) - // because /api/channels/{id}/messages excludes thread replies. - let last_event = batch.events.last()?; - let tags = crate::queue::parse_thread_tags(&last_event.event); - if let Some(root_id) = tags.root_event_id { - return fetch_thread_context( - batch.channel_id, - &root_id, - limit, - ctx.agent_keys.public_key(), - &ctx.rest_client, - ) - .await; + match resolve_context_target(batch, is_dm) { + ContextTarget::Thread(root_id) => { + fetch_thread_context( + batch.channel_id, + &root_id, + limit, + ctx.agent_keys.public_key(), + &ctx.rest_client, + ) + .await + } + ContextTarget::Dm => fetch_dm_context(batch.channel_id, limit, &ctx.rest_client).await, + ContextTarget::None => None, } +} - // DM non-reply: fetch recent conversation history. +/// Which history to fetch for a batch's context section. +#[derive(Debug, PartialEq, Eq)] +enum ContextTarget { + /// Fetch the canonical thread rooted at this event id. + Thread(String), + /// Fetch recent DM conversation history. + Dm, + /// No supplementary context (new thread's first turn, or plain channel). + None, +} + +/// Decide which history to gather, driven by the batch's resolved +/// [`SessionScope`] — never by inferring scope from the last event. +/// +/// - Thread scope: the canonical root is authoritative. +/// - Conversation scope (DMs always; channels under `channel` policy): a +/// threaded reply fetches its reply chain; a DM non-reply fetches recent +/// conversation history; a plain top-level channel message has none. +fn resolve_context_target(batch: &FlushBatch, is_dm: bool) -> ContextTarget { + if let Some(root_id) = batch.scope.root_event_id() { + return ContextTarget::Thread(root_id.to_string()); + } + let Some(last_event) = batch.events.last() else { + return ContextTarget::None; + }; + if let Some(root_id) = crate::queue::parse_thread_tags(&last_event.event).root_event_id { + return ContextTarget::Thread(root_id); + } if is_dm { - return fetch_dm_context(batch.channel_id, limit, &ctx.rest_client).await; + return ContextTarget::Dm; } - - None + ContextTarget::None } /// Normalize AND validate a pubkey for the batch profile API request. @@ -6781,6 +6820,98 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" assert_eq!(s.sessions.get(&tb).map(String::as_str), Some("b")); } + fn batch_with_scope(scope: SessionScope, event: nostr::Event) -> FlushBatch { + FlushBatch { + channel_id: scope.channel_id(), + scope, + events: vec![crate::queue::BatchEvent { + event, + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + } + } + + fn signed_event_with_tags(tags: Vec>) -> nostr::Event { + let keys = Keys::generate(); + let tags: Vec = tags.into_iter().map(|t| Tag::parse(t).unwrap()).collect(); + EventBuilder::new(Kind::Custom(9), "hi") + .tags(tags) + .sign_with_keys(&keys) + .unwrap() + } + + #[test] + fn context_target_uses_thread_scope_root_not_last_event_tags() { + let ch = Uuid::new_v4(); + let scope_root = "a".repeat(64); + // Last event carries a DIFFERENT root tag than the scope; the scope + // must win so context is gathered for the canonical thread. + let ev = signed_event_with_tags(vec![vec![ + "e".into(), + "b".repeat(64), + String::new(), + "root".into(), + ]]); + let batch = batch_with_scope(thread_scope(ch, &scope_root), ev); + assert_eq!( + resolve_context_target(&batch, false), + ContextTarget::Thread(scope_root) + ); + } + + #[test] + fn context_target_new_top_level_thread_has_no_history() { + // A top-level mention opens a thread rooted at its own id; on the first + // turn there is no prior thread history to fetch, but the scope still + // resolves to that root (subsequent turns fetch it). + let ch = Uuid::new_v4(); + let ev = signed_event_with_tags(vec![]); + let root = ev.id.to_hex(); + let batch = batch_with_scope(thread_scope(ch, &root), ev); + assert_eq!( + resolve_context_target(&batch, false), + ContextTarget::Thread(root) + ); + } + + #[test] + fn context_target_conversation_channel_plain_has_none() { + // Channel-policy conversation scope + a plain (no-thread-tag) event => + // no unrelated channel transcript is injected. + let ch = Uuid::new_v4(); + let ev = signed_event_with_tags(vec![]); + let batch = batch_with_scope(conv(ch), ev); + assert_eq!(resolve_context_target(&batch, false), ContextTarget::None); + } + + #[test] + fn context_target_dm_nonreply_is_dm_history() { + let ch = Uuid::new_v4(); + let ev = signed_event_with_tags(vec![]); + let batch = batch_with_scope(conv(ch), ev); + assert_eq!(resolve_context_target(&batch, true), ContextTarget::Dm); + } + + #[test] + fn context_target_conversation_reply_uses_reply_chain() { + // DM (or legacy channel-policy) reply: conversation scope but the last + // event has thread tags => fetch that reply chain. + let ch = Uuid::new_v4(); + let root = "c".repeat(64); + let ev = signed_event_with_tags(vec![ + vec!["e".into(), root.clone(), String::new(), "root".into()], + vec!["e".into(), "d".repeat(64), String::new(), "reply".into()], + ]); + let batch = batch_with_scope(conv(ch), ev); + assert_eq!( + resolve_context_target(&batch, true), + ContextTarget::Thread(root) + ); + } + #[test] fn invalidate_channel_clears_every_thread_scope() { let ch = Uuid::new_v4(); From f8eaa71763baa9fa7d07be95a066aab383c7e0e2 Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Tue, 25 Aug 2026 16:14:42 -0400 Subject: [PATCH 5/9] fix(buzz-acp): normalize thread-root case so equivalent spellings share one scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared NIP-10 parser accepts and preserves uppercase ASCII hex in `e`-tag marker ids (`is_ascii_hexdigit`), but the relay decodes event ids to bytes on ingest — so a reply tagging its root as `AB…` and one tagging `ab…` are the SAME accepted relay thread. `SessionScope::Thread` keyed on the raw string, so under thread policy those equivalent spellings hashed to different keys and split one thread across two ACP sessions (queue partitions, provider sessions, worker affinity, and delivery ledgers), violating same-root reuse. Normalize the resolved root id to lowercase in `SessionScope::derive` before it becomes the scope key. `nostr::EventId::to_hex()` is already lowercase, so the top-level-mention path is unaffected. Adds a mixed-case regression test. Reported in review of PR #6732. Signed-off-by: Salman Mohammed --- crates/buzz-acp/src/scope.rs | 51 +++++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/crates/buzz-acp/src/scope.rs b/crates/buzz-acp/src/scope.rs index 45e85855815..21f897f3093 100644 --- a/crates/buzz-acp/src/scope.rs +++ b/crates/buzz-acp/src/scope.rs @@ -107,20 +107,27 @@ impl SessionScope { /// [`buzz_core::nip10`] canonical-root rules — a malformed marker id is /// ignored (treated as top-level), and a lone `root` marker with no `reply` /// is top-level, matching relay ingest. + /// + /// The root id is normalized to lowercase before it becomes the scope key. + /// The shared NIP-10 parser accepts and preserves uppercase ASCII hex + /// (`is_ascii_hexdigit`), but the relay decodes event ids to bytes on + /// ingest, so `AB…` and `ab…` name the *same* thread. Without normalization + /// those equivalent spellings would hash to different `Thread` keys and + /// split one relay thread across two ACP sessions (queue state, provider + /// sessions, affinity, delivery ledgers). `nostr::EventId::to_hex()` is + /// already lowercase, so the top-level path is unaffected. pub fn derive(policy: SessionPolicy, channel_id: Uuid, is_dm: bool, event: &Event) -> Self { if is_dm || policy == SessionPolicy::Channel { return Self::Conversation { channel_id }; } - match parse_thread_tags(event).root_event_id { - Some(root_event_id) => Self::Thread { - channel_id, - root_event_id, - }, - None => Self::Thread { - channel_id, - root_event_id: event.id.to_hex(), - }, + let root_event_id = match parse_thread_tags(event).root_event_id { + Some(root) => root, + None => event.id.to_hex(), + }; + Self::Thread { + channel_id, + root_event_id: root_event_id.to_ascii_lowercase(), } } @@ -271,6 +278,32 @@ mod tests { ); } + #[test] + fn mixed_case_root_spellings_share_one_thread_scope() { + // The relay decodes event ids to bytes, so `AB…` and `ab…` name the + // same thread. Equivalent-case root tags must resolve to the SAME + // `SessionScope::Thread` key, or thread state would split in two. + let ch = Uuid::new_v4(); + let root_lower = "a1b2c3d4e5f6".repeat(4) + &"0".repeat(16); // 64 hex + assert_eq!(root_lower.len(), 64); + let root_upper = root_lower.to_ascii_uppercase(); + + let mk = |root: &str| { + event_with_tags(vec![ + vec!["e".into(), root.to_string(), String::new(), "root".into()], + vec!["e".into(), "f".repeat(64), String::new(), "reply".into()], + ]) + }; + let lower = SessionScope::derive(SessionPolicy::Thread, ch, false, &mk(&root_lower)); + let upper = SessionScope::derive(SessionPolicy::Thread, ch, false, &mk(&root_upper)); + assert_eq!( + lower, upper, + "case-equivalent root spellings must share one thread scope" + ); + // And the stored key is normalized to lowercase. + assert_eq!(upper.root_event_id(), Some(root_lower.as_str())); + } + #[test] fn malformed_thread_tag_falls_back_to_top_level() { let ch = Uuid::new_v4(); From 4bb9bc44774f266ad0dff12f914f2865fda04d14 Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Wed, 26 Aug 2026 08:41:59 -0400 Subject: [PATCH 6/9] fix(buzz-acp): close three thread-scope isolation gaps in session routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses three correctness gaps found in review of PR #6732. 1. Duplicate provider session for a busy thread (pool.rs, lib.rs). Worker affinity only scanned idle slots, so while the worker that owns a thread's session was checked out, a new message for that thread could be handed to another idle worker, forking a second session and splitting the thread's history/tool context. Added an authoritative `SessionScope -> worker` directory (`session_owners`) that survives while a worker is checked out. `dispatch_pending` now holds a batch (leaves it queued) when its session owner is busy, instead of forking a duplicate; the held batch dispatches to that exact worker when it returns. The directory is pruned on channel-wide session invalidation, and stale entries (rotation / crash) self-heal on the next dispatch. 2. Mid-turn steer/interrupt could target the wrong thread (lib.rs). The native-steer fallback and the steer-ack fallback routed by channel via `signal_in_flight_task`, which picks the first task for the channel — so a message in thread A could interrupt thread B in the same channel. Added `signal_in_flight_task_for_scope` (exact `SessionScope` match) and used it for both mid-turn fallbacks. The deferred channel-level control paths (`!cancel`, `!rotate`, observer `cancel_turn` / `switch_model`) keep channel-targeting intentionally. 3. Panicked thread stayed "in flight" (lib.rs). `recover_panicked_agent` called `mark_complete(channel_id)`, which resolves to `Conversation(channel_id)` via IntoScope and, under thread policy, left the real `Thread(...)` entry wedged in-flight until the ~2h backstop — blocking the batch it had just requeued. It now uses `meta.scope`. Tests: scope-exact signalling targets only the matching thread; a busy session owner holds the batch instead of forking a session (and the directory prunes on channel invalidation); panic recovery frees the exact Thread scope and requeues its batch. Full buzz-acp suite (831 lib + integration) green; clippy and fmt clean. Signed-off-by: Salman Mohammed --- crates/buzz-acp/src/lib.rs | 296 +++++++++++++++++++++++++++++++++++- crates/buzz-acp/src/pool.rs | 38 +++++ 2 files changed, 326 insertions(+), 8 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 210cc4295ee..69e7f280c1c 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -3000,9 +3000,12 @@ async fn tokio_main() -> Result<()> { &steer_ack_tx, ); if !native_attempted { - signal_in_flight_task( + // Scope-exact so a mid-turn steer for + // this event's thread never interrupts a + // different thread in the same channel. + signal_in_flight_task_for_scope( &mut pool, - buzz_event.channel_id, + &session_scope, signal, ); } @@ -3373,10 +3376,12 @@ async fn tokio_main() -> Result<()> { 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 - // 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); + // front of `queues[scope]`, so the cancel will pick + // it up as part of the merged batch and re-prompt the + // agent. Scope-exact so the fallback cancels the + // steered event's OWN thread, not a sibling thread + // in the same channel. + signal_in_flight_task_for_scope(&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 @@ -3613,6 +3618,14 @@ fn mode_gate_signal( } /// Send a control signal to the in-flight task for `channel_id`. +/// +/// Channel-targeted: picks the first in-flight task matching the channel. Used +/// only by the explicitly-deferred channel-level control paths (`!cancel`, +/// `!rotate`, observer `cancel_turn` / `switch_model`). For per-thread mid-turn +/// steering/interruption use [`signal_in_flight_task_for_scope`], which targets +/// one exact [`scope::SessionScope`] so a message in thread A can never +/// interrupt thread B running in the same channel. +/// /// Returns `true` if a signal was sent, `false` if no in-flight task was found. fn signal_in_flight_task( pool: &mut AgentPool, @@ -3634,6 +3647,39 @@ fn signal_in_flight_task( false } +/// Send a control signal to the in-flight task for one exact session scope. +/// +/// The scope-precise counterpart of [`signal_in_flight_task`]: mid-turn +/// steer/interrupt must target the thread the triggering event belongs to, not +/// “whichever task the channel happens to have first” — otherwise two threads +/// running concurrently in one channel could steer each other. +/// +/// Returns `true` if a signal was sent, `false` if no in-flight task matched. +fn signal_in_flight_task_for_scope( + pool: &mut AgentPool, + scope: &scope::SessionScope, + mode: ControlSignal, +) -> bool { + let entry = pool + .task_map_mut() + .values_mut() + .find(|m| m.scope.as_ref() == Some(scope)); + + if let Some(meta) = entry { + if let Some(tx) = meta.control_tx.take() { + tracing::info!( + channel = %scope.channel_id(), + scope = %scope.telemetry_label(), + ?mode, + "control signal sent to in-flight task (scope-exact)" + ); + let _ = tx.send(mode); + return true; + } + } + false +} + /// Attempt the non-cancelling (ACP) steer for a freshly-queued event. /// /// Caller invariants: @@ -3754,6 +3800,12 @@ fn dispatch_pending( last_activity: &mut tokio::time::Instant, ) -> Vec<(Uuid, ThreadTags)> { let mut dispatched_channels = Vec::new(); + // Batches held back this cycle because the worker that owns their thread's + // session is busy. They stay flushed-out of the queue (in-flight) until we + // release them at the end so `flush_next` cannot re-pick them mid-loop; + // releasing requeues them so the next dispatch (when the owner returns) + // reuses that exact session instead of forking a duplicate. + let mut held: Vec = Vec::new(); loop { let batch = match queue.flush_next() { Some(b) => b, @@ -3761,6 +3813,18 @@ fn dispatch_pending( }; let channel_id = batch.channel_id; let scope = batch.scope.clone(); + // Authoritative affinity: if the worker that owns this thread's session + // is checked out (busy on another turn), hold the batch rather than let + // an idle worker open a second session for the same thread. + if pool.should_hold_for_busy_owner(&scope) { + tracing::debug!( + channel = %channel_id, + scope = %scope.telemetry_label(), + "holding batch — session owner busy; awaiting its return to avoid duplicate session" + ); + held.push(batch); + continue; + } let typing_scope = batch .events .last() @@ -3828,7 +3892,7 @@ fn dispatch_pending( pool::TaskMeta { agent_index, channel_id: Some(channel_id), - scope: Some(scope), + scope: Some(scope.clone()), turn_id, recoverable_batch, control_tx: Some(control_tx), @@ -3836,9 +3900,21 @@ fn dispatch_pending( successful_steer_deliveries: HashSet::new(), }, ); + // Record this worker as the scope's session owner so a later dispatch + // while it is busy holds instead of forking a duplicate session. + pool.record_scope_owner(scope, agent_index); dispatched_channels.push((channel_id, typing_scope)); *last_activity = tokio::time::Instant::now(); } + // Release held batches back to the queue (owner busy). They were flushed + // out (in-flight) so they could not be re-picked above; requeue preserves + // their timestamps and mark_complete clears the in-flight marker, leaving + // them queued for the next dispatch when the owner frees up. + for batch in held { + let scope = batch.scope.clone(); + queue.requeue_preserve_timestamps(batch); + queue.mark_complete(scope); + } tracing::debug!( dispatched = dispatched_channels.len(), queue_depth = queue.pending_channels(), @@ -4325,7 +4401,15 @@ fn recover_panicked_agent( } if let Some(ch) = meta.channel_id { - queue.mark_complete(ch); + // Clear the EXACT session scope, not the channel. Passing a bare + // channel id would resolve to `Conversation(channel_id)` via IntoScope + // and, under thread policy, leave the actual `Thread(...)` entry wedged + // in-flight until the ~2h backstop deadline — blocking the batch we + // just requeued. `meta.scope` is the authoritative in-flight scope. + match &meta.scope { + Some(scope) => queue.mark_complete(scope.clone()), + None => queue.mark_complete(ch), + } typing_channels.remove(&ch); tracing::warn!("cleared wedged in-flight channel {ch} from panicked agent {i}"); } else { @@ -5287,6 +5371,105 @@ mod owner_control_command_tests { )); } + fn thread_scope(channel_id: Uuid, root: &str) -> scope::SessionScope { + scope::SessionScope::Thread { + channel_id, + root_event_id: root.to_string(), + } + } + + fn insert_task_meta( + pool: &mut AgentPool, + agent_index: usize, + scope: scope::SessionScope, + control_tx: tokio::sync::oneshot::Sender, + ) { + let abort_handle = pool.join_set.spawn(async {}); + pool.task_map_mut().insert( + abort_handle.id(), + pool::TaskMeta { + agent_index, + channel_id: Some(scope.channel_id()), + scope: Some(scope), + turn_id: "t".to_string(), + recoverable_batch: None, + control_tx: Some(control_tx), + steer_tx: None, + successful_steer_deliveries: HashSet::new(), + }, + ); + } + + // Fix #2: mid-turn steer/interrupt must target the exact thread scope, not + // “the first task in the channel” — two threads in one channel must not + // interrupt each other. + #[tokio::test] + async fn signal_in_flight_task_for_scope_targets_only_matching_thread() { + let mut pool = AgentPool::from_slots(vec![]); + let ch = Uuid::new_v4(); + let ta = thread_scope(ch, &"a".repeat(64)); + let tb = thread_scope(ch, &"b".repeat(64)); + let (tx_a, rx_a) = tokio::sync::oneshot::channel(); + let (tx_b, rx_b) = tokio::sync::oneshot::channel(); + insert_task_meta(&mut pool, 0, ta.clone(), tx_a); + insert_task_meta(&mut pool, 1, tb.clone(), tx_b); + + // Signalling thread A must reach A's task only. + assert!(signal_in_flight_task_for_scope( + &mut pool, + &ta, + ControlSignal::Steer + )); + assert_eq!(rx_a.await.unwrap(), ControlSignal::Steer); + + // Thread B's control channel is untouched (still open, no signal). + assert!(signal_in_flight_task_for_scope( + &mut pool, + &tb, + ControlSignal::Interrupt + )); + assert_eq!(rx_b.await.unwrap(), ControlSignal::Interrupt); + + // A scope with no in-flight task returns false. + assert!(!signal_in_flight_task_for_scope( + &mut pool, + &thread_scope(ch, &"c".repeat(64)), + ControlSignal::Steer + )); + } + + // Fix #1: a thread must not get a second provider session when the worker + // that owns its session is busy on another turn. + #[tokio::test] + async fn busy_session_owner_holds_batch_instead_of_forking_session() { + let mut pool = AgentPool::from_slots(vec![]); + let ch = Uuid::new_v4(); + let ta = thread_scope(ch, &"a".repeat(64)); + let tb = thread_scope(ch, &"b".repeat(64)); + + // Worker 0 owns thread A's session and is currently busy running B. + pool.record_scope_owner(ta.clone(), 0); + let (tx_b, _rx_b) = tokio::sync::oneshot::channel(); + insert_task_meta(&mut pool, 0, tb.clone(), tx_b); + + // A new A message must be HELD (owner busy, no idle worker holds A). + assert!( + pool.should_hold_for_busy_owner(&ta), + "owner busy => hold to avoid a duplicate session" + ); + + // A brand-new thread with no recorded owner is never held. + assert!(!pool.should_hold_for_busy_owner(&thread_scope(ch, &"d".repeat(64)))); + + // Channel-wide session invalidation prunes the directory so a stale + // owner can never strand a held batch. + pool.invalidate_channel_sessions(ch); + assert!( + !pool.should_hold_for_busy_owner(&ta), + "owner directory pruned on channel invalidation" + ); + } + #[test] fn project_owner_control_signs_only_addressable_project_events() { let keys = Keys::generate(); @@ -7520,6 +7703,103 @@ mod error_outcome_emission_tests { assert_eq!(panic.turn_id.as_deref(), Some("panic-turn-id")); } + // Fix #3: a panicked thread-scoped task must clear its EXACT scope from the + // in-flight set (via meta.scope), not `Conversation(channel_id)`. Otherwise + // the requeued batch stays wedged until the ~2h in-flight backstop. + #[tokio::test] + async fn panic_recovery_frees_the_exact_thread_scope() { + let mut pool = AgentPool::from_slots(vec![]); + let channel_id = Uuid::new_v4(); + let scope = scope::SessionScope::Thread { + channel_id, + root_event_id: "a".repeat(64), + }; + + // A thread-scoped batch is in flight (queue marks the Thread scope). + let mut queue = EventQueue::new(config::DedupMode::Queue); + let event = EventBuilder::new(Kind::Custom(9), "x") + .tags([]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + queue.push(queue::QueuedEvent { + channel_id, + scope: scope.clone(), + event, + received_at: std::time::Instant::now(), + prompt_tag: "t".into(), + }); + let batch = queue.flush_next().expect("flush thread batch"); + assert!(queue.is_scope_in_flight(&scope)); + + // Spawn a task we can panic/abort, wired to the same scope + a + // recoverable batch so recovery requeues it. + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let abort_handle = pool.join_set.spawn(async move { + let _ = started_tx.send(()); + std::future::pending::<()>().await; + }); + pool.task_map_mut().insert( + abort_handle.id(), + crate::pool::TaskMeta { + agent_index: 0, + channel_id: Some(channel_id), + scope: Some(scope.clone()), + turn_id: "panic-turn-id".to_string(), + recoverable_batch: Some(batch), + control_tx: None, + steer_tx: None, + successful_steer_deliveries: HashSet::new(), + }, + ); + started_rx.await.unwrap(); + abort_handle.abort(); + let join_error = pool.join_set.join_next().await.unwrap().unwrap_err(); + + let config = test_config(); + let mut heartbeat_in_flight = false; + let removed_channels = HashSet::new(); + let mut typing_channels = HashMap::new(); + // Pre-open the circuit so recovery returns before attempting a real + // respawn subprocess (mark_complete runs before the circuit check). + let mut crash_history = vec![SlotCircuit { + crash_times: Vec::new(), + open_until: Some(std::time::Instant::now() + Duration::from_secs(3600)), + respawn_in_flight: false, + }]; + let (respawn_tx, _respawn_rx) = mpsc::channel(8); + let mut respawn_tasks = tokio::task::JoinSet::new(); + + recover_panicked_agent( + &mut pool, + &mut queue, + &config, + join_error, + &mut heartbeat_in_flight, + &removed_channels, + &mut typing_channels, + &mut crash_history, + &respawn_tx, + &mut respawn_tasks, + None, + ); + + // The exact Thread scope is freed and the requeued batch is flushable + // again immediately — not stranded behind a Conversation(channel_id) + // entry until the backstop deadline. + assert!( + !queue.is_scope_in_flight(&scope), + "panic recovery must clear the exact Thread scope" + ); + // The requeued batch is queued again (recovery uses `requeue`, which + // applies a short retry backoff — so it is undispatched work now and + // becomes flushable once the backoff expires, rather than being stranded + // in-flight behind the wrong scope until the ~2h backstop). + assert!( + queue.has_undispatched_work(), + "requeued thread batch must be queued (undispatched) after recovery" + ); + } + #[tokio::test] async fn idle_timeout_emits_exactly_one_feed_event() { assert_eq!( diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index d71c2ed6885..9cae195494b 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -329,6 +329,13 @@ pub struct AgentPool { result_rx: mpsc::UnboundedReceiver, pub join_set: JoinSet<()>, task_map: HashMap, + /// Authoritative directory of which worker most recently owned each session + /// scope's provider session. Survives while a worker is checked out (its + /// `SessionState` is invisible to the pool then), so a busy owner does not + /// cause another worker to open a duplicate session for the same thread. + /// Best-effort: stale entries (rotation, crash/respawn) self-heal on the + /// next dispatch and are pruned on channel-wide session invalidation. + session_owners: HashMap, } /// Result returned by a completed prompt task. @@ -704,6 +711,32 @@ impl AgentPool { result_rx, join_set: JoinSet::new(), task_map: HashMap::new(), + session_owners: HashMap::new(), + } + } + + /// Record which worker is handling `scope` so a later dispatch can detect a + /// busy owner and avoid opening a duplicate session on another worker. + pub fn record_scope_owner(&mut self, scope: SessionScope, agent_index: usize) { + self.session_owners.insert(scope, agent_index); + } + + /// True when this scope should be **held** (left queued) rather than + /// dispatched to a fresh worker, because the worker that owns its provider + /// session is currently checked out (busy on another turn). + /// + /// Only holds when no idle worker already holds the session + /// ([`has_session_for`](Self::has_session_for) is false): if an idle owner + /// exists, [`try_claim`](Self::try_claim) reuses it directly. Holding waits + /// for the busy owner to return so its exact session (and tool/turn + /// context) is reused, instead of forking a second session for the thread. + pub fn should_hold_for_busy_owner(&self, scope: &SessionScope) -> bool { + if self.has_session_for(scope) { + return false; + } + match self.session_owners.get(scope) { + Some(&owner_idx) => self.task_map.values().any(|m| m.agent_index == owner_idx), + None => false, } } @@ -907,6 +940,11 @@ impl AgentPool { count += agent.state.invalidate_channel(&channel_id); } } + // Drop every scope-owner entry for this channel so the directory does + // not grow without bound and cannot strand a held batch behind a stale + // owner after the channel's sessions are gone. + self.session_owners + .retain(|scope, _| scope.channel_id() != channel_id); count } From 326bd45588ecf87aa2a226f3637433a0baf6ebd4 Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Wed, 26 Aug 2026 11:22:47 -0400 Subject: [PATCH 7/9] fix(buzz-acp): preserve cancelled carryover when re-queuing a held/exhausted batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `requeue_preserve_timestamps` restored only `batch.events`, silently dropping `batch.cancelled_events` and `cancel_reason`. Both callers pass a full `FlushBatch` that can carry an interrupted turn's original request: - the new busy-owner affinity hold (thread scoping), and - the pre-existing "no agent available" pool-exhausted path. Reachable loss: thread A is interrupted (Buzz retains A's original request to re-prompt "original + follow-up"); older work in thread B grabs A's session-owning worker; A's merged batch is held; only the follow-up was put back and the original request vanished. The helper now restores the entire batch — cancelled carryover is returned to the pending cancelled-batches (ahead of any concurrently staged carryover) with its reason, so the next flush reconstructs the same merged prompt. Adds a queue-level round-trip regression asserting events + cancelled_events + cancel_reason all survive requeue -> mark_complete -> flush. Reported in review of PR #6732. Full buzz-acp suite (832 lib + integration) green; clippy and fmt clean. Signed-off-by: Salman Mohammed --- crates/buzz-acp/src/queue.rs | 81 +++++++++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 5 deletions(-) diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 29e0871ad3c..3f99046a021 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -615,17 +615,42 @@ impl EventQueue { None } - /// Re-queue a batch preserving original `received_at` timestamps. + /// Re-queue a **complete** flushed batch preserving original `received_at` + /// timestamps. /// - /// Used when a batch was flushed but no agent was available — we want to - /// retry without penalizing the channel's position in the fairness queue - /// and without imposing a retry throttle. + /// Used when a batch was flushed but could not run — no agent was available, + /// or the batch's session-owning worker was busy (thread-scope affinity + /// hold) — so we retry without penalizing the scope's fairness position and + /// without imposing a retry throttle. /// - /// Does NOT set `retry_after`. Does NOT remove from `in_flight_channels` — + /// Restores the **entire** batch, not just `events`: any + /// [`cancelled_events`](FlushBatch::cancelled_events) and their + /// [`cancel_reason`](FlushBatch::cancel_reason) are returned to the pending + /// cancelled-carryover so the next flush reconstructs the same merged + /// (interrupt/steer) prompt. Dropping them here would silently lose the + /// original request of an interrupted turn. + /// + /// 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 channel_id = batch.channel_id; let scope = batch.scope.clone(); + + // Restore cancelled carryover FIRST so it precedes any carryover a + // concurrent cancel may have already staged for this scope, preserving + // original-before-newer ordering. `flush_next` re-merges it as the next + // batch's `cancelled_events`. + if !batch.cancelled_events.is_empty() { + let existing = self.cancelled_batches.remove(&scope).unwrap_or_default(); + let mut restored = batch.cancelled_events; + restored.extend(existing); + self.cancelled_batches.insert(scope.clone(), restored); + if let Some(reason) = batch.cancel_reason { + // Keep the most recent reason if one was already staged. + self.cancel_reasons.entry(scope.clone()).or_insert(reason); + } + } + 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() { @@ -3279,6 +3304,52 @@ mod tests { assert_eq!(batch2.events[0].received_at, original_received_at); } + #[test] + fn test_requeue_preserve_timestamps_round_trips_cancelled_carryover() { + // Regression: a held/exhausted merged batch (cancel + re-prompt) must + // not lose its original request. requeue_preserve_timestamps must + // restore events AND cancelled_events + cancel_reason so the next flush + // reconstructs the same merged batch. + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + let scope = conv(ch); + let batch = FlushBatch { + channel_id: ch, + scope: scope.clone(), + events: vec![BatchEvent { + event: make_event("the follow-up"), + prompt_tag: "@mention".into(), + received_at: Instant::now(), + }], + cancelled_events: vec![BatchEvent { + event: make_event("the original request"), + prompt_tag: "@mention".into(), + received_at: Instant::now(), + }], + cancel_reason: Some(CancelReason::Interrupt), + }; + // Simulate the flushed-then-held state: scope is in-flight. + q.push(make_queued(ch, "placeholder")); + let _ = q.flush_next().expect("scope now in-flight"); + + q.requeue_preserve_timestamps(batch); + q.mark_complete(scope); + + let restored = q.flush_next().expect("merged batch re-flushes"); + assert_eq!(restored.events.len(), 1); + assert_eq!(restored.events[0].event.content, "the follow-up"); + assert_eq!( + restored.cancelled_events.len(), + 1, + "cancelled carryover (original request) must survive the requeue" + ); + assert_eq!( + restored.cancelled_events[0].event.content, + "the original request" + ); + assert_eq!(restored.cancel_reason, Some(CancelReason::Interrupt)); + } + #[test] fn test_requeue_preserve_timestamps_no_retry_after() { let mut q = EventQueue::new(DedupMode::Queue); From f8f88088905369222f7c03fbdfd198e7c54ea162 Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Wed, 26 Aug 2026 18:08:06 -0400 Subject: [PATCH 8/9] chore(acp): fix main merge test lint Signed-off-by: Salman Mohammed --- crates/buzz-acp/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index ad22c55917f..7d485394eb6 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -8709,7 +8709,7 @@ mod error_outcome_emission_tests { .map(String::as_str), Some("healthy-session") ); - assert_eq!(queue.queued_event_count(&channel_id), 1); + assert_eq!(queue.queued_event_count(channel_id), 1); assert!(crash_history[0].crash_times.is_empty()); assert!(crash_history[0].open_until.is_none()); assert!(!crash_history[0].respawn_in_flight); From 52f3ee72627122a34cbeef5366af45582e3dc1ce Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Thu, 27 Aug 2026 16:20:47 -0400 Subject: [PATCH 9/9] fix(acp): scope-exact !cancel/!rotate and per-thread typing state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two thread-isolation correctness bugs surfaced in review, both only reachable under BUZZ_ACP_SESSION_POLICY=thread; behavior under the default channel policy is unchanged (the scope is the channel's sole conversation). !cancel / !rotate could hit the wrong thread. Both routed through signal_in_flight_task, which matches on channel_id and takes the first HashMap entry — so with two threads running concurrently in one channel an owner's !cancel/!rotate could tear down an arbitrary thread's turn. They now derive the SessionScope from the command event's NIP-10 tags (the same resolver admission uses) and target it via signal_in_flight_task_for_scope, the scope-exact primitive steering already uses. The idle !rotate path now invalidates only that scope via the new AgentPool::invalidate_scope_session instead of the whole channel. signal_in_flight_task is now used only by the desktop observer control frames (cancel_turn / switch_model), which carry a bare channelId and no thread context. Typing state was channel-keyed. typing_channels keyed by channel_id, so two concurrent thread turns overwrote one entry and either turn's completion removed it — the indicator could reflect the wrong thread or stop while a sibling turn continued. It is now keyed by SessionScope: dispatch_pending returns the scope, completion/panic/ownership-removal clear the exact scope (via the new PromptSource::scope accessor), and the refresh loop publishes one indicator per active thread carrying that thread's NIP-10 tags. Tests: invalidate_scope_session targets one thread and drops its owner; PromptSource::scope exposes the thread scope and None for heartbeats. The reviewer's P1 (unbounded session/provider-resource retention) is a pre-existing lifecycle-hardening concern, not thread-scoping-specific, and is tracked separately rather than adding partial eviction here. Signed-off-by: Salman Mohammed --- crates/buzz-acp/src/lib.rs | 184 +++++++++++++++++++++++------------- crates/buzz-acp/src/pool.rs | 95 +++++++++++++++++++ 2 files changed, 211 insertions(+), 68 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 7d485394eb6..fd9ad42684a 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -2266,7 +2266,7 @@ async fn tokio_main() -> Result<()> { } else { None }; - let mut typing_channels: HashMap = HashMap::new(); + let mut typing_channels: HashMap = HashMap::new(); let mut presence_task: Option> = None; // Independent of pool readiness: a never-mentioned lazy agent must still @@ -2478,10 +2478,10 @@ 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 + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } } @@ -2530,10 +2530,10 @@ 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 + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } @@ -2724,7 +2724,9 @@ 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); + // Drop every thread scope's typing entry for + // the removed channel. + typing_channels.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 @@ -2796,21 +2798,36 @@ async fn tokio_main() -> Result<()> { &pubkey_hex, ); 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, - buzz_event.channel_id, - ControlSignal::Cancel, + let from_owner = owner_cache.get().is_some_and(|owner| { + buzz_event.event.pubkey.to_hex() == *owner + }); + if from_owner { + // Scope-exact: an owner's !cancel in thread A + // must cancel thread A's turn, never a sibling + // thread running in the same channel. Under + // the default channel policy the scope is the + // channel's sole conversation, so this is + // byte-for-byte the prior behavior. + let scope = scope::SessionScope::derive( + config.session_policy, + buzz_event.channel_id, + is_dm_channel(buzz_event.channel_id, &ctx.channel_info) + .await, + &buzz_event.event, + ); + let fired = signal_in_flight_task_for_scope( + &mut pool, + &scope, + ControlSignal::Cancel, + ); + if !fired { + tracing::warn!( + channel_id = %buzz_event.channel_id, + scope = %scope.telemetry_label(), + "!cancel received but no in-flight task — no-op" ); - if !fired { - tracing::warn!( - channel_id = %buzz_event.channel_id, - "!cancel received but no in-flight task — no-op" - ); - } - continue; // consume event — do NOT push to queue } + continue; // consume event — do NOT push to queue } // Not from owner — fall through to normal prompt handling. } @@ -2834,28 +2851,44 @@ async fn tokio_main() -> Result<()> { &pubkey_hex, ); 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, - buzz_event.channel_id, - ControlSignal::Rotate, + let from_owner = owner_cache.get().is_some_and(|owner| { + buzz_event.event.pubkey.to_hex() == *owner + }); + if from_owner { + // Scope-exact: rotate only the thread the + // owner's !rotate belongs to. Under the + // default channel policy the scope is the + // channel's sole conversation, matching the + // prior channel-wide rotate. + let scope = scope::SessionScope::derive( + config.session_policy, + buzz_event.channel_id, + is_dm_channel(buzz_event.channel_id, &ctx.channel_info) + .await, + &buzz_event.event, + ); + let fired = signal_in_flight_task_for_scope( + &mut pool, + &scope, + ControlSignal::Rotate, + ); + if fired { + tracing::info!( + channel_id = %buzz_event.channel_id, + scope = %scope.telemetry_label(), + "!rotate received — cancelling in-flight turn and rotating session" + ); + } else { + let invalidated = + pool.invalidate_scope_session(&scope); + tracing::info!( + channel_id = %buzz_event.channel_id, + scope = %scope.telemetry_label(), + invalidated, + "!rotate received — invalidated idle session for scope" ); - 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)" - ); - } - continue; // consume event — do NOT push to queue } + continue; // consume event — do NOT push to queue } // Not from owner — fall through to normal prompt handling. } @@ -3014,10 +3047,10 @@ async fn tokio_main() -> Result<()> { } } if pool_ready { - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } } @@ -3114,10 +3147,10 @@ async fn tokio_main() -> Result<()> { tracing::debug!("heartbeat_skipped_pool_not_ready"); } else 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, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } else if pool.any_idle() { dispatch_heartbeat(&mut pool, &ctx, &mut heartbeat_in_flight); @@ -3156,7 +3189,8 @@ 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_channels { + let ch = scope.channel_id(); if let Ok(event) = relay.build_typing_event( ch, thread_tags.root_event_id.as_deref(), @@ -3178,9 +3212,11 @@ async fn tokio_main() -> Result<()> { match pool_event { Some(PoolEvent::Result(result)) => { - // Stop typing indicator for the completed channel. - if let Some(ch) = result.source.channel_id() { - typing_channels.remove(&ch); + // Stop the typing indicator for the completed turn's exact scope, + // not the whole channel — a sibling thread still running in the + // same channel must keep its indicator. + if let Some(scope) = result.source.scope() { + typing_channels.remove(scope); } if handle_prompt_result( &mut pool, @@ -3213,10 +3249,10 @@ async fn tokio_main() -> Result<()> { { break; } - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } Some(PoolEvent::Panic(join_error)) => { @@ -3238,10 +3274,10 @@ async fn tokio_main() -> Result<()> { tracing::error!("all agents dead — exiting"); break; } - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } Some(PoolEvent::SteerAck(SteerAckEvent { @@ -3392,10 +3428,10 @@ async fn tokio_main() -> Result<()> { // 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 + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } Some(PoolEvent::Wake(attempt, result)) => { @@ -3420,10 +3456,10 @@ async fn tokio_main() -> Result<()> { "ready", None, ); - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } Err(error) => { @@ -3622,11 +3658,13 @@ fn mode_gate_signal( /// Send a control signal to the in-flight task for `channel_id`. /// /// Channel-targeted: picks the first in-flight task matching the channel. Used -/// only by the explicitly-deferred channel-level control paths (`!cancel`, -/// `!rotate`, observer `cancel_turn` / `switch_model`). For per-thread mid-turn -/// steering/interruption use [`signal_in_flight_task_for_scope`], which targets -/// one exact [`scope::SessionScope`] so a message in thread A can never -/// interrupt thread B running in the same channel. +/// only by the desktop observer control frames (`cancel_turn` / `switch_model`), +/// which carry a bare `channelId` and no thread context. Every thread-aware +/// path — mid-turn steering/interruption and the owner `!cancel` / `!rotate` +/// commands, whose triggering event carries NIP-10 thread tags — uses +/// [`signal_in_flight_task_for_scope`], which targets one exact +/// [`scope::SessionScope`] so a signal for thread A can never hit thread B +/// running in the same channel. /// /// Returns `true` if a signal was sent, `false` if no in-flight task was found. fn signal_in_flight_task( @@ -3806,7 +3844,10 @@ fn dispatch_pending( queue: &mut EventQueue, ctx: &Arc, last_activity: &mut tokio::time::Instant, -) -> Vec<(Uuid, ThreadTags)> { +) -> Vec<(scope::SessionScope, ThreadTags)> { + // Keyed by the exact session scope, not the channel: two threads dispatching + // concurrently in one channel get distinct typing entries so completing one + // never clears the other's indicator. let mut dispatched_channels = Vec::new(); // Batches held back this cycle because the worker that owns their thread's // session is busy. They stay flushed-out of the queue (in-flight) until we @@ -3910,8 +3951,8 @@ fn dispatch_pending( ); // Record this worker as the scope's session owner so a later dispatch // while it is busy holds instead of forking a duplicate session. - pool.record_scope_owner(scope, agent_index); - dispatched_channels.push((channel_id, typing_scope)); + pool.record_scope_owner(scope.clone(), agent_index); + dispatched_channels.push((scope, typing_scope)); *last_activity = tokio::time::Instant::now(); } // Release held batches back to the queue (owner busy). They were flushed @@ -4390,7 +4431,7 @@ fn recover_panicked_agent( join_error: tokio::task::JoinError, heartbeat_in_flight: &mut bool, removed_channels: &HashSet, - typing_channels: &mut HashMap, + typing_channels: &mut HashMap, crash_history: &mut [SlotCircuit], respawn_tx: &mpsc::Sender, respawn_tasks: &mut tokio::task::JoinSet<()>, @@ -4427,10 +4468,17 @@ fn recover_panicked_agent( // in-flight until the ~2h backstop deadline — blocking the batch we // just requeued. `meta.scope` is the authoritative in-flight scope. match &meta.scope { - Some(scope) => queue.mark_complete(scope.clone()), - None => queue.mark_complete(ch), + Some(scope) => { + // Clear the panicked turn's exact scope so a sibling thread in + // the same channel keeps its typing indicator. + typing_channels.remove(scope); + queue.mark_complete(scope.clone()); + } + None => { + typing_channels.retain(|scope, _| scope.channel_id() != ch); + queue.mark_complete(ch); + } } - typing_channels.remove(&ch); tracing::warn!("cleared wedged in-flight channel {ch} from panicked agent {i}"); } else { *heartbeat_in_flight = false; @@ -4496,7 +4544,7 @@ fn drain_ready_join_results( config: &Config, heartbeat_in_flight: &mut bool, removed_channels: &HashSet, - typing_channels: &mut HashMap, + typing_channels: &mut HashMap, crash_history: &mut [SlotCircuit], respawn_tx: &mpsc::Sender, respawn_tasks: &mut tokio::task::JoinSet<()>, diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 25325765a8e..73137aebc28 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -370,6 +370,19 @@ impl PromptSource { Self::Heartbeat => None, } } + + /// The exact session scope this prompt belongs to, or `None` for + /// heartbeats. Callers that must target the precise thread (e.g. clearing a + /// typing indicator on completion) use this rather than [`channel_id`], so a + /// finishing turn never disturbs a sibling thread in the same channel. + /// + /// [`channel_id`]: PromptSource::channel_id + pub fn scope(&self) -> Option<&SessionScope> { + match self { + Self::Channel(scope) => Some(scope), + Self::Heartbeat => None, + } + } } /// Apply state effects for Race 1, where a control signal arrives just after the @@ -1017,6 +1030,27 @@ impl AgentPool { count } + /// Invalidate the session for one exact scope across every worker, and drop + /// its scope-owner entry. The scope-precise counterpart of + /// [`invalidate_channel_sessions`](Self::invalidate_channel_sessions): under + /// thread policy an idle `!rotate` in thread A must rotate only thread A's + /// session, leaving sibling threads in the same channel untouched. Under the + /// default channel policy the scope is `Conversation(channel_id)` — the sole + /// scope for the channel — so this matches the channel-wide behavior. + /// Returns the number of workers that held a session for the scope. + pub fn invalidate_scope_session(&mut self, scope: &SessionScope) -> usize { + let mut count = 0; + for slot in &mut self.agents { + if let Some(agent) = slot.as_mut() { + if agent.state.invalidate_scope(scope) { + count += 1; + } + } + } + self.session_owners.remove(scope); + count + } + /// Idle-path model switch: set `desired_model` on the idle agent for /// `channel_id` and invalidate its session so the next turn re-creates the /// session under the new model. @@ -7218,6 +7252,67 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" assert!(s.sessions.keys().all(|k| k.channel_id() == other)); } + #[test] + fn prompt_source_scope_exposes_thread_scope_and_none_for_heartbeat() { + let ch = Uuid::new_v4(); + let scope = thread_scope(ch, &"a".repeat(64)); + let channel = PromptSource::Channel(scope.clone()); + // The scope-precise accessor returns the exact thread so a completing + // turn clears only its own typing indicator. + assert_eq!(channel.scope(), Some(&scope)); + assert_eq!(channel.channel_id(), Some(ch)); + assert_eq!(PromptSource::Heartbeat.scope(), None); + } + + #[tokio::test] + async fn invalidate_scope_session_targets_one_thread_and_drops_its_owner() { + // The idle `!rotate` path: rotating thread A must invalidate only thread + // A's session and drop its scope-owner entry, leaving a sibling thread + // in the same channel fully intact. + let ch = Uuid::new_v4(); + let ta = thread_scope(ch, &"a".repeat(64)); + let tb = thread_scope(ch, &"b".repeat(64)); + let acp = AcpClient::spawn("bash", &["-c".into(), "sleep 10".into()], &[], false) + .await + .expect("spawn dummy ACP"); + let mut agent = OwnedAgent { + index: 0, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: None, + model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: None, + agent_name: "test".into(), + goose_system_prompt_supported: None, + protocol_version: 2, + }; + agent.state.sessions.insert(ta.clone(), "sess-a".into()); + agent.state.sessions.insert(tb.clone(), "sess-b".into()); + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + pool.record_scope_owner(ta.clone(), 0); + pool.record_scope_owner(tb.clone(), 0); + + let cleared = pool.invalidate_scope_session(&ta); + + assert_eq!(cleared, 1, "exactly one worker held thread A's session"); + assert!(!pool.has_session_for(&ta), "thread A session invalidated"); + assert!( + pool.has_session_for(&tb), + "sibling thread B session survives" + ); + assert!( + !pool.session_owners.contains_key(&ta), + "thread A owner dropped" + ); + assert!( + pool.session_owners.contains_key(&tb), + "thread B owner retained" + ); + } + #[test] fn test_rotate_after_natural_completion_invalidates_channel_state() { let (mut s, ch_a, ch_b) = make_state();