diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index b9c840624..a7f7563f4 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -142,10 +142,19 @@ The gate applies to **all** inbound events — @mentions, DMs, thread replies, a | Command | Effect | |---------|--------| | `!shutdown` | Gracefully exits the harness. | -| `!cancel` | Cancels the current in-flight turn for that channel, if any. | -| `!rotate` | Rotates the ACP session for that channel. If a turn is in-flight, it is cancelled and the channel session is invalidated when the task returns; otherwise the cached idle session is invalidated immediately. The next queued/received event starts a fresh session. | +| `!cancel` | Cancels the current in-flight turn for the conversation the command targets, if any. | +| `!rotate` | Rotates the ACP session for the conversation the command targets. If a turn is in-flight for that conversation, it is cancelled and the session is invalidated when the task returns; otherwise the cached idle session is invalidated immediately. The next queued/received event in that conversation starts a fresh session. | -Use `!cancel` to stop only the current turn; it is a no-op when the channel is idle. Use `!rotate` when you want the next turn in the channel to start from a fresh ACP session, even if the channel is currently idle. +`!cancel` and `!rotate` are **conversation-scoped** (see [Session Scope](#session-scope)): +send the command **as a reply inside a thread** to target that thread's session, +or as a **bare (unthreaded) channel message** to target the channel-level +conversation. A thread-scoped command never cancels or rotates sibling threads +or the channel conversation, and a channel-level command never destroys thread +sessions. + +Use `!cancel` to stop only the current turn; it is a no-op when the targeted +conversation is idle. Use `!rotate` when you want the next turn in that +conversation to start from a fresh ACP session, even if it is currently idle. Owner control commands must be kind:9 stream messages from the owner, must mention this agent with a `p` tag, and are consumed by the harness instead of being forwarded to the agent. @@ -193,7 +202,55 @@ buzz-acp --agents 2 --heartbeat-interval 300 \ ### Shared Identity -All N agents authenticate as the **same Nostr bot identity** — users see one bot regardless of how many agents are running. The same channel is never processed by two agents simultaneously (the queue enforces this). Cross-channel message ordering is not guaranteed when N>1. +All N agents authenticate as the **same Nostr bot identity** — users see one bot regardless of how many agents are running. The same conversation (see [Session Scope](#session-scope)) is never processed by two agents simultaneously (the queue enforces this); different conversations — including different threads in one channel — can run concurrently when N>1. Cross-conversation message ordering is not guaranteed when N>1. + +### Session Scope + +The harness keys ACP sessions (and queueing, steering, turn counters, and +`!cancel`/`!rotate` targeting) by **conversation**, not just by channel: + +- **Unthreaded channel messages** share one channel-level conversation — one + continuous ACP session per channel, as before. +- **Thread replies** in a regular channel are scoped by `(channel, thread + root)`: every thread gets its own isolated ACP session. Replies in the same + thread reuse that thread's session; separate threads in the same channel + never share or pollute each other's sessions, and can be in-flight + simultaneously. +- **Forum posts** (kind 45001) are thread roots: a post scopes to its own + event ID, and comments on it (kind 45003) resolve to the same + conversation, so a forum post and its comment thread share one session + while separate posts in the same channel stay isolated. +- **DMs** always use channel-level continuity — one session per DM + conversation, even for replies with thread tags. +- **Channels joined after startup** resolve and cache their metadata before + the harness opens event delivery. Failed lookups retry while the membership + remains active. The first delivered event therefore scopes normally — DMs + channel-level, regular/forum channels per-thread — without an + event-before-metadata fallback that could split one conversation. + +For stream messages the thread root is the canonical NIP-10 `root` marker +from the reply's `e` tags. Note a stream thread's root message is itself an +unthreaded channel message — it runs in the channel-level conversation; the +thread's own session begins with the first reply. + +**Stable agent ownership (N > 1):** a conversation's session history lives in +exactly one agent process, so each conversation is pinned to the agent that +serves it. If that agent is busy on other work when new events arrive, the +conversation waits for it (fairness position preserved) instead of being +forked onto another agent. Ownership is released when the session is rotated +or invalidated, or when the owning agent process dies — the next turn then +starts a fresh session on any agent. + +**Desktop model switching** is channel-level desired state: a `switch_model` +control frame updates every idle agent holding sessions under the channel, +signals every in-flight conversation under it, and is inherited by any agent +that later picks up a conversation in that channel. + +When the agent is removed from a channel, every conversation under that +channel — channel-level and all threads — is drained and its sessions are +invalidated. This is agent conversation routing only: channel identity, +subscriptions, and NIP-29 authorization remain keyed by the `h`-tag channel +UUID. ### Heartbeat Semantics @@ -242,12 +299,12 @@ Forum event kinds: 1. **Startup** — Spawns N agent subprocesses (default 1), sends ACP `initialize` to each, connects to the relay with NIP-42 auth. 2. **Channel discovery** — Queries the relay REST API for accessible channels, subscribes to each. -3. **Event loop** — Listens for @mention events (kind 9 with the agent's pubkey in a `#p` tag). Events queue per channel. -4. **Prompting** — When events are pending and no prompt is in flight for that channel, drains all queued events for the oldest channel into a single batched prompt via ACP `session/prompt`. +3. **Event loop** — Listens for @mention events (kind 9 with the agent's pubkey in a `#p` tag). Events queue per conversation (channel-level or thread — see [Session Scope](#session-scope)). +4. **Prompting** — When events are pending and no prompt is in flight for that conversation, drains all queued events for the oldest conversation into a single batched prompt via ACP `session/prompt`. Events from different conversations are never batched together. 5. **Agent response** — The agent processes the prompt and uses the Buzz CLI (`send_message`, `get_messages`, etc.) to interact with Buzz. 6. **Recovery** — If the agent crashes, the harness respawns it. If the relay disconnects, the harness reconnects with a `since` filter to avoid missing events. -Each channel has at most one prompt in flight. Multiple channels can be processed concurrently when agents > 1. +Each conversation has at most one prompt in flight. Multiple conversations — including multiple threads within one channel — can be processed concurrently when agents > 1. > **Note:** On startup, the harness replays all unprocessed @mentions since the last run. Expect a burst of activity if there are stale events in the channel. diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 923e13216..8c20abee1 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -8,6 +8,7 @@ mod observer; mod pool; mod queue; mod relay; +mod scope; mod setup_mode; mod usage; @@ -41,6 +42,7 @@ use pool::{ }; use queue::{CancelReason, EventQueue, FlushBatch, QueuedEvent, ThreadTags}; use relay::{HarnessRelay, RelayEventPublisher}; +use scope::ConversationScope; use tokio::sync::{mpsc, watch}; use tracing_subscriber::EnvFilter; use uuid::Uuid; @@ -832,8 +834,10 @@ fn handle_cancel_turn_control( return; }; - let fired = signal_in_flight_task(pool, channel_id, ControlSignal::Cancel); - let status = if fired { "sent" } else { "no_active_turn" }; + // Desktop control frames are addressed by channel only; cancel every + // in-flight conversation under the channel (channel-level + threads). + let fired = signal_in_flight_tasks_for_channel(pool, channel_id, ControlSignal::Cancel); + let status = if fired > 0 { "sent" } else { "no_active_turn" }; if let Some(observer) = observer { observer.emit( "control_result", @@ -881,33 +885,39 @@ fn handle_switch_model_control( return; }; - // A turn is in flight for this channel iff a task_map entry exists. The - // agent is moved out of the pool during a turn, so the control oneshot is - // the only reachable lever; an idle channel has no such entry. - let turn_in_flight = pool - .task_map() - .values() - .any(|m| m.channel_id == Some(channel_id)); - - let status = if turn_in_flight { - // Busy path: deliver over the oneshot. `false` means the oneshot was - // already consumed this turn (a prior cancel/interrupt) — the turn is - // already ending, so the switch cannot land on it. - if signal_in_flight_task( + // Desktop model selection is channel-level desired state. Record it and + // apply it to every idle session holder under the channel FIRST (also + // pre-validating against the cached catalog), then signal every active + // conversation under the channel. Agents that later claim a conversation + // in this channel inherit the desired model at claim time, so idle, + // active, and future holders all converge on the same model. + let idle_result = pool.set_channel_desired_model(channel_id, model_id); + let status = if idle_result == IdleSwitchResult::UnsupportedModel { + // Reject without disturbing any session — idle or active. + "unsupported_model" + } else { + // Fan out to every in-flight conversation under the channel (desktop + // frames carry no thread identity). `0` fired with a turn in flight + // means every oneshot was already consumed (a prior cancel/interrupt) + // — those turns are ending; the recorded desired state still lands on + // their requeued/next turns via claim-time inheritance. + let turn_in_flight = pool + .task_map() + .values() + .any(|m| m.scope.is_some_and(|s| s.channel_id == channel_id)); + let fired = signal_in_flight_tasks_for_channel( pool, channel_id, ControlSignal::SwitchModel(model_id.to_string()), - ) { + ); + if fired > 0 { "sent" - } else { + } else if turn_in_flight { "turn_ending" - } - } else { - // Idle path: validate against the cached catalog before invalidating. - match pool.switch_idle_agent_model(channel_id, model_id) { - IdleSwitchResult::Switched => "switched", - IdleSwitchResult::UnsupportedModel => "unsupported_model", - IdleSwitchResult::NoIdleAgent => "no_active_turn", + } else if idle_result == IdleSwitchResult::Switched { + "switched" + } else { + "no_active_turn" } }; @@ -1079,10 +1089,11 @@ struct RespawnResult { /// stream. /// /// Carries enough identity to operate on the right withheld event in -/// `EventQueue::withheld_native_steer`: `channel_id` is the routing key, -/// `event_id` is the hex id of the single event the steer carried. +/// `EventQueue::withheld_native_steer`: the conversation `scope` is the +/// routing key, `event_id` is the hex id of the single event the steer +/// carried. struct SteerAckEvent { - channel_id: Uuid, + scope: ConversationScope, 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 @@ -1092,6 +1103,39 @@ struct SteerAckEvent { ack: std::result::Result, } +/// A dynamically joined channel is not exposed to event delivery until its +/// metadata is cached. The membership event ID is a generation token: a late +/// resolver result is ignored if the channel was removed/re-added meanwhile. +#[derive(Debug, Clone, PartialEq, Eq)] +struct DynamicChannelSubscriptionReady { + channel_id: Uuid, + membership_event_id: String, + replay_since: u64, +} + +const DYNAMIC_CHANNEL_METADATA_RETRY_DELAY: Duration = Duration::from_secs(5); + +/// Resolve metadata before signalling that a dynamic subscription may open. +/// Failed lookups retry indefinitely while the membership remains pending; +/// the caller owns the task handle and aborts it on removal/shutdown. +async fn resolve_metadata_before_dynamic_subscribe( + mut resolve: F, + ready: DynamicChannelSubscriptionReady, + ready_tx: mpsc::UnboundedSender, + retry_delay: Duration, +) where + F: FnMut() -> Fut, + Fut: std::future::Future, +{ + loop { + if resolve().await { + let _ = ready_tx.send(ready); + return; + } + tokio::time::sleep(retry_delay).await; + } +} + /// RAII guard that ensures a `RespawnResult` is sent even if the task panics. /// Without this, a panicked respawn task would leave `respawn_in_flight = true` /// permanently, silently losing the slot forever. @@ -1288,6 +1332,7 @@ async fn tokio_main() -> Result<()> { acp, state: SessionState::default(), model_capabilities: None, + baseline_model: config.model.clone(), desired_model: config.model.clone(), model_overridden: false, agent_name, @@ -1545,7 +1590,7 @@ async fn tokio_main() -> Result<()> { .to_string_lossy() .to_string(), rest_client: relay.rest_client(), - channel_info: channel_info_map, + channel_info: std::sync::RwLock::new(channel_info_map), context_message_limit: config.context_message_limit, max_turns_per_session: config.max_turns_per_session, permission_mode: config.permission_mode, @@ -1594,7 +1639,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; // Runs at the TOP of every loop iteration via Instant check — cannot be @@ -1621,6 +1666,17 @@ async fn tokio_main() -> Result<()> { // `IN_FLIGHT_DEADLINE_SECS` expires. let (steer_ack_tx, mut steer_ack_rx) = mpsc::unbounded_channel::(); + // Metadata resolution gate for channels joined after startup. Resolver + // tasks never subscribe directly: they populate `ctx.channel_info` first, + // then notify the main loop, which opens event delivery only if the same + // membership generation is still pending. + let (dynamic_channel_ready_tx, mut dynamic_channel_ready_rx) = + mpsc::unbounded_channel::(); + let mut pending_dynamic_channel_subscriptions: HashMap< + Uuid, + (String, tokio::task::JoinHandle<()>), + > = HashMap::new(); + // ── Step 7: Shutdown signal ─────────────────────────────────────────────── let (shutdown_tx, mut shutdown_rx) = watch::channel(()); @@ -1731,8 +1787,8 @@ async fn tokio_main() -> Result<()> { // called on relay events or pool results, neither of which // arrive when the channel is silent. if queue.has_flushable_work() { - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { - typing_channels.insert(channel_id, thread_tags); + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + typing_channels.insert(scope, thread_tags); } } } @@ -1747,6 +1803,7 @@ async fn tokio_main() -> Result<()> { acp, state: SessionState::default(), model_capabilities: None, + baseline_model: config.model.clone(), desired_model: config.model.clone(), model_overridden: false, agent_name, @@ -1767,8 +1824,8 @@ async fn tokio_main() -> Result<()> { // this, batches requeued during crash recovery sit idle until the // next relay event arrives — which can be minutes on quiet channels. if respawn_collected { - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { - typing_channels.insert(channel_id, thread_tags); + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + typing_channels.insert(scope, thread_tags); } } @@ -1822,6 +1879,75 @@ async fn tokio_main() -> Result<()> { } None } + Some(ready) = dynamic_channel_ready_rx.recv() => { + let _ = result_rx; + let still_pending = pending_dynamic_channel_subscriptions + .get(&ready.channel_id) + .is_some_and(|(event_id, _)| { + event_id == &ready.membership_event_id + }); + if !still_pending { + tracing::debug!( + channel_id = %ready.channel_id, + "ignoring stale dynamic-channel metadata result" + ); + } else if removed_channels.contains(&ready.channel_id) { + if let Some((_, task)) = pending_dynamic_channel_subscriptions + .remove(&ready.channel_id) + { + task.abort(); + } + } else if subscribed_channel_ids.contains(&ready.channel_id) { + pending_dynamic_channel_subscriptions.remove(&ready.channel_id); + } else if let Some(filter) = config::resolve_dynamic_channel_filter( + &config, + ready.channel_id, + &rules, + ) { + tracing::info!( + channel_id = %ready.channel_id, + "metadata cached: subscribing to dynamically joined channel" + ); + match relay + .subscribe_channel_from( + ready.channel_id, + filter, + Some(ready.replay_since), + ) + .await + { + Ok(()) => { + subscribed_channel_ids.insert(ready.channel_id); + pending_dynamic_channel_subscriptions + .remove(&ready.channel_id); + } + Err(error) => { + tracing::warn!( + channel_id = %ready.channel_id, + "failed to subscribe to dynamically joined channel: {error}; retrying" + ); + let retry_ready = ready.clone(); + let retry_tx = dynamic_channel_ready_tx.clone(); + let retry_task = tokio::spawn(async move { + tokio::time::sleep( + DYNAMIC_CHANNEL_METADATA_RETRY_DELAY, + ) + .await; + let _ = retry_tx.send(retry_ready); + }); + pending_dynamic_channel_subscriptions.insert( + ready.channel_id, + (ready.membership_event_id, retry_task), + ); + } + } + } else if let Some((_, task)) = pending_dynamic_channel_subscriptions + .remove(&ready.channel_id) + { + task.abort(); + } + None + } // Remaining branches don't touch pool — evaluated when pool is idle. buzz_event = relay.next_event() => { let _ = result_rx; // end split borrow before relay handling @@ -1861,7 +1987,7 @@ async fn tokio_main() -> Result<()> { ); continue; } - seen_membership_current.insert(eid); + seen_membership_current.insert(eid.clone()); // Rotate at 1000: current → previous, no amnesia window. if seen_membership_current.len() >= 1000 { seen_membership_previous = @@ -1888,17 +2014,55 @@ async fn tokio_main() -> Result<()> { if subscribed_channel_ids.contains(&ch) { tracing::debug!(channel_id = %ch, "membership notification: channel already subscribed"); - } else if let Some(filter) = config::resolve_dynamic_channel_filter(&config, ch, &rules) { - tracing::info!(channel_id = %ch, "membership notification: subscribing to new channel"); - if let Err(e) = relay.subscribe_channel_from(ch, filter, Some(ts)).await { - tracing::warn!("failed to subscribe to new channel {ch}: {e}"); - } else { - subscribed_channel_ids.insert(ch); + } else if config::resolve_dynamic_channel_filter(&config, ch, &rules).is_some() { + tracing::info!( + channel_id = %ch, + "membership notification: resolving metadata before subscribing" + ); + if let Some((_, prior)) = + pending_dynamic_channel_subscriptions.remove(&ch) + { + prior.abort(); } + let ready = DynamicChannelSubscriptionReady { + channel_id: ch, + membership_event_id: eid, + replay_since: ts, + }; + let membership_event_id = + ready.membership_event_id.clone(); + let ctx_for_refresh = Arc::clone(&ctx); + let ready_tx = dynamic_channel_ready_tx.clone(); + let task = tokio::spawn(async move { + resolve_metadata_before_dynamic_subscribe( + || { + let ctx_for_refresh = + Arc::clone(&ctx_for_refresh); + async move { + pool::refresh_channel_info( + &ctx_for_refresh, + ch, + ) + .await + } + }, + ready, + ready_tx, + DYNAMIC_CHANNEL_METADATA_RETRY_DELAY, + ) + .await; + }); + pending_dynamic_channel_subscriptions + .insert(ch, (membership_event_id, task)); } else { tracing::debug!(channel_id = %ch, "membership notification: no matching rules — skipping"); } } else { + if let Some((_, pending)) = + pending_dynamic_channel_subscriptions.remove(&ch) + { + pending.abort(); + } subscribed_channel_ids.remove(&ch); tracing::info!(channel_id = %ch, "membership notification: unsubscribing from channel"); if let Err(e) = relay.unsubscribe_channel(ch).await { @@ -1913,7 +2077,7 @@ async fn tokio_main() -> Result<()> { // Track removed channels so checked-out agents get // their sessions stripped when they return to the pool. removed_channels.insert(ch); - typing_channels.remove(&ch); + typing_channels.retain(|s, _| s.channel_id != ch); // Best-effort: clean up 👀 on drained events. // Note: the relay revokes membership before // emitting the notification, so this DELETE may @@ -1946,6 +2110,21 @@ async fn tokio_main() -> Result<()> { continue; } + // Conversation scope for this event: thread replies + // in regular channels get their own scope; DM + // traffic — and channels whose type is unknown + // (not in the startup discovery cache) — keeps + // channel-level continuity. Owner control commands + // below target this same scope, so a `!cancel` / + // `!rotate` sent as a thread reply acts on that + // thread only, and a bare channel-level command + // acts on the channel conversation only. + let scope = conversation_scope_for_event( + &ctx, + buzz_event.channel_id, + &buzz_event.event, + ); + // Check: kind:9, content "!shutdown", from owner, mentions THIS agent. let is_shutdown = is_owner_control_command( &buzz_event.event, @@ -1989,13 +2168,13 @@ async fn tokio_main() -> Result<()> { if buzz_event.event.pubkey.to_hex() == *owner { let fired = signal_in_flight_task( &mut pool, - buzz_event.channel_id, + scope, ControlSignal::Cancel, ); if !fired { tracing::warn!( - channel_id = %buzz_event.channel_id, - "!cancel received but no in-flight task — no-op" + scope = %scope, + "!cancel received but no in-flight task for this conversation — no-op" ); } continue; // consume event — do NOT push to queue @@ -2027,20 +2206,20 @@ async fn tokio_main() -> Result<()> { if buzz_event.event.pubkey.to_hex() == *owner { let fired = signal_in_flight_task( &mut pool, - buzz_event.channel_id, + scope, ControlSignal::Rotate, ); if fired { tracing::info!( - channel_id = %buzz_event.channel_id, + scope = %scope, "!rotate received — cancelling in-flight turn and rotating session" ); } else { - let invalidated = pool.invalidate_channel_sessions(buzz_event.channel_id); + let invalidated = pool.invalidate_scope_sessions(scope); tracing::info!( - channel_id = %buzz_event.channel_id, + scope = %scope, invalidated, - "!rotate received — invalidated idle channel session(s)" + "!rotate received — invalidated idle session(s) for this conversation" ); } continue; // consume event — do NOT push to queue @@ -2106,7 +2285,7 @@ async fn tokio_main() -> Result<()> { let event_for_steer = buzz_event.event.clone(); let prompt_tag_for_steer = prompt_tag.clone(); let accepted = queue.push(QueuedEvent { - channel_id: buzz_event.channel_id, + scope, event: buzz_event.event, received_at: std::time::Instant::now(), prompt_tag, @@ -2124,9 +2303,12 @@ 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) { + // the conversation has an in-flight task, fire cancel — + // OR take the non-cancelling (ACP steer) fork for Steer + // signals. Scoped per conversation: a reply landing in + // thread A never steers or cancels a turn running for + // thread B or for the channel-level conversation. + if accepted && queue.is_scope_in_flight(scope) { // Author eligibility (owner ∪ allowlist ∪ siblings) // is already enforced by the inbound author gate // above, so the mid-turn signal fires for every @@ -2153,7 +2335,7 @@ async fn tokio_main() -> Result<()> { && try_native_steer( &mut pool, &mut queue, - buzz_event.channel_id, + scope, event_for_steer, prompt_tag_for_steer, &steer_ack_tx, @@ -2161,16 +2343,16 @@ async fn tokio_main() -> Result<()> { if !native_attempted { signal_in_flight_task( &mut pool, - buzz_event.channel_id, + scope, signal, ); } } } - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } None => { @@ -2235,14 +2417,14 @@ 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 { if let Ok(event) = relay.build_typing_event( - ch, + scope.channel_id, thread_tags.root_event_id.as_deref(), thread_tags.parent_event_id.as_deref(), ) { if let Err(e) = relay.try_publish_event(event) { - tracing::debug!("typing indicator dropped for {ch}: {e}"); + tracing::debug!("typing indicator dropped for {scope}: {e}"); } } } @@ -2257,9 +2439,9 @@ 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); + // Stop typing indicator for the completed conversation. + if let PromptSource::Conversation(scope) = &result.source { + typing_channels.remove(scope); } if handle_prompt_result( &mut pool, @@ -2292,8 +2474,8 @@ async fn tokio_main() -> Result<()> { { break; } - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { - typing_channels.insert(channel_id, thread_tags); + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + typing_channels.insert(scope, thread_tags); } } Some(PoolEvent::Panic(join_error)) => { @@ -2315,12 +2497,12 @@ async fn tokio_main() -> Result<()> { tracing::error!("all agents dead — exiting"); break; } - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { - typing_channels.insert(channel_id, thread_tags); + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + typing_channels.insert(scope, thread_tags); } } Some(PoolEvent::SteerAck(SteerAckEvent { - channel_id, + scope, event_id, ack, })) => { @@ -2403,7 +2585,7 @@ async fn tokio_main() -> Result<()> { Err(_recv_err) => (true, false, false), }; tracing::info!( - channel = %channel_id, + scope = %scope, event_id = %event_id, ?ack, release_withheld, @@ -2412,37 +2594,40 @@ async fn tokio_main() -> Result<()> { "non-cancelling steer ack received" ); if matches!(ack, Ok(pool::SteerAck::Success)) { - queue.extend_in_flight_deadline(channel_id, config.max_turn_duration_secs); + queue.extend_in_flight_deadline(scope, config.max_turn_duration_secs); } if drop_withheld { - queue.remove_event(channel_id, &event_id); + queue.remove_event(scope, &event_id); } if release_withheld { - queue.release_native_steer(channel_id, &event_id); + queue.release_native_steer(scope, &event_id); } if signal_fallback { // Universal cancel+merge fallback. Note: the // queued event has already been released to the - // front of `queues[channel_id]`, so the cancel + // front of `queues[scope]`, so the cancel // will pick it up as part of the merged batch and // re-prompt the agent. - signal_in_flight_task(&mut pool, channel_id, ControlSignal::Steer); + signal_in_flight_task(&mut pool, scope, ControlSignal::Steer); } // After releasing a withheld event, give dispatch a chance // to re-flush. If the prompt is still in flight, the - // channel stays `in_flight_channels` and `flush_next` + // conversation stays in `in_flight_scopes` and `flush_next` // skips it — but a Steer fallback signal sent above will // tear down the in-flight task; on its completion the // queue drains. We still try here in case the in-flight // task has already returned. - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { - typing_channels.insert(channel_id, thread_tags); + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + typing_channels.insert(scope, thread_tags); } } None => {} // relay/heartbeat/shutdown branches handled inline above } } + for (_, (_, task)) in pending_dynamic_channel_subscriptions.drain() { + task.abort(); + } tracing::info!("shutdown: waiting for in-flight prompts"); // 30 s is generous for in-flight prompts to be cancelled; using // max_turn_duration here would cause Ctrl+C to hang for up to an hour. @@ -2567,6 +2752,37 @@ fn is_owner_control_command( && event_mentions_agent(event, agent_pubkey_hex) } +/// Whether events in `channel_id` are eligible for thread-scoped sessions. +/// +/// True only for channels the startup discovery cache confirms are not DMs. +/// DMs keep channel-level continuity (one session per DM conversation), and +/// channels whose type is unknown — e.g. joined after startup, before any +/// metadata is cached — conservatively fall back to channel scope, which +/// matches the pre-thread-scoping behavior and is always correct for DMs. +fn channel_supports_thread_scoping( + channel_info: &HashMap, + channel_id: Uuid, +) -> bool { + channel_info + .get(&channel_id) + .is_some_and(|ci| ci.channel_type != "dm") +} + +fn conversation_scope_for_event( + ctx: &PromptContext, + channel_id: Uuid, + event: &nostr::Event, +) -> ConversationScope { + let thread_scoped = { + let info = ctx + .channel_info + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + channel_supports_thread_scoping(&info, channel_id) + }; + ConversationScope::for_event(channel_id, event, thread_scoped) +} + // ── signal_in_flight_task ───────────────────────────────────────────────────── /// Decide which [`ControlSignal`] (if any) to send to an in-flight turn when a @@ -2595,21 +2811,21 @@ fn mode_gate_signal( } } -/// Send a control signal to the in-flight task for `channel_id`. +/// Send a control signal to the in-flight task for the conversation `scope`. /// Returns `true` if a signal was sent, `false` if no in-flight task was found. fn signal_in_flight_task( pool: &mut AgentPool, - channel_id: uuid::Uuid, + scope: ConversationScope, mode: ControlSignal, ) -> bool { let entry = pool .task_map_mut() .values_mut() - .find(|m| m.channel_id == Some(channel_id)); + .find(|m| m.scope == Some(scope)); if let Some(meta) = entry { if let Some(tx) = meta.control_tx.take() { - tracing::info!(channel = %channel_id, ?mode, "control signal sent to in-flight task"); + tracing::info!(scope = %scope, ?mode, "control signal sent to in-flight task"); let _ = tx.send(mode); return true; } @@ -2617,10 +2833,36 @@ fn signal_in_flight_task( false } +/// Send a control signal to every in-flight task under `channel_id` — the +/// channel-level conversation and any thread conversations. +/// +/// Used by desktop observer control frames, which are addressed by channel +/// only (they carry no thread identity). Returns the number of tasks +/// signalled. +fn signal_in_flight_tasks_for_channel( + pool: &mut AgentPool, + channel_id: uuid::Uuid, + mode: ControlSignal, +) -> usize { + let mut fired = 0; + for meta in pool.task_map_mut().values_mut() { + let Some(scope) = meta.scope else { continue }; + if scope.channel_id != channel_id { + continue; + } + if let Some(tx) = meta.control_tx.take() { + tracing::info!(scope = %scope, ?mode, "control signal sent to in-flight task"); + let _ = tx.send(mode.clone()); + fired += 1; + } + } + fired +} + /// Attempt the non-cancelling (ACP) steer for a freshly-queued event. /// /// Caller invariants: -/// - `event` has already been pushed into `EventQueue::queues[channel_id]` +/// - `event` has already been pushed into `EventQueue::queues[scope]` /// via [`EventQueue::push`] — its `event.id` must still be locatable /// there so [`EventQueue::mark_native_steer_pending`] can move it to the /// side table. @@ -2636,7 +2878,7 @@ fn signal_in_flight_task( /// Returns `false` if `pool.send_steer` failed (no in-flight task, /// `steer_tx` already full from a prior in-flight steer, or read loop /// torn down). The caller MUST fall through to -/// `signal_in_flight_task(channel_id, ControlSignal::Steer)` so the +/// `signal_in_flight_task(scope, ControlSignal::Steer)` so the /// event still reaches the agent via the universal path. /// /// The withheld event is NOT released here on `false` because no withhold @@ -2644,7 +2886,7 @@ fn signal_in_flight_task( fn try_native_steer( pool: &mut AgentPool, queue: &mut EventQueue, - channel_id: uuid::Uuid, + scope: ConversationScope, event: nostr::Event, prompt_tag: String, steer_ack_tx: &mpsc::UnboundedSender, @@ -2669,7 +2911,7 @@ fn try_native_steer( prompt_tag: prompt_tag.clone(), received_at: std::time::Instant::now(), }; - let event_block = queue::format_event_block(channel_id, None, &be, None); + let event_block = queue::format_event_block(scope.channel_id, None, &be, None); let body = format!("{header}\n\n[Buzz event: {prompt_tag}]\n{event_block}\n\n{closing}"); let (ack_tx, ack_rx) = tokio::sync::oneshot::channel::(); @@ -2678,14 +2920,14 @@ fn try_native_steer( ack_tx, }; - match pool.send_steer(channel_id, request) { + match pool.send_steer(scope, request) { Ok(()) => { // Withhold the queued event synchronously BEFORE spawning // the watcher: this closes the race where `mark_complete` - // clears `in_flight_channels` and a stray `flush_next` could + // clears `in_flight_scopes` and a stray `flush_next` could // re-deliver the event via normal dispatch. See // `EventQueue::mark_native_steer_pending` docs at queue.rs:606. - let withheld = queue.mark_native_steer_pending(channel_id, &event_id_hex); + 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 @@ -2695,7 +2937,7 @@ fn try_native_steer( // the same message twice). Log so this is visible if it // ever happens in production. tracing::warn!( - channel = %channel_id, + scope = %scope, event_id = %event_id_hex, "native steer accepted by read loop but event was not in queue to withhold \ — possible duplicate delivery if steer succeeds" @@ -2706,7 +2948,7 @@ fn try_native_steer( tokio::spawn(async move { let ack = ack_rx.await; let _ = ack_tx_clone.send(SteerAckEvent { - channel_id, + scope, event_id: event_id_for_watcher, ack, }); @@ -2715,7 +2957,7 @@ fn try_native_steer( } Err(e) => { tracing::info!( - channel = %channel_id, + scope = %scope, error = ?e, "non-cancelling steer not accepted — falling back to cancel+merge" ); @@ -2727,35 +2969,50 @@ fn try_native_steer( // ── dispatch_pending ────────────────────────────────────────────────────────── /// Flush queued work to available agents. +/// +/// Conversation ownership is stable: a scope whose owning agent is checked +/// out is **deferred** (left queued with its fairness position preserved), +/// not handed to another agent — that would fork the ACP session history. +/// Deferred scopes are parked in-flight for the duration of the loop so +/// `flush_next` moves on to other conversations, then requeued at the end; +/// they are retried on the next dispatch cycle (every pool result triggers +/// one, so the owner's return re-dispatches them promptly). fn dispatch_pending( pool: &mut AgentPool, queue: &mut EventQueue, ctx: &Arc, -) -> Vec<(Uuid, ThreadTags)> { - let mut dispatched_channels = Vec::new(); +) -> Vec<(ConversationScope, ThreadTags)> { + let mut dispatched_conversations = Vec::new(); + let mut deferred: Vec = Vec::new(); loop { let batch = match queue.flush_next() { Some(b) => b, None => break, }; - let channel_id = batch.channel_id; + let scope = batch.scope; 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)) { - 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); + let affinity_hit = pool.has_session_for(scope); + let mut agent = match pool.try_claim_for_scope(scope) { + pool::ClaimOutcome::Claimed(a) => *a, + pool::ClaimOutcome::OwnerBusy => { + tracing::debug!(scope = %scope, "owner_busy — deferring conversation"); + // Keep the scope marked in-flight for now so flush_next + // proceeds to other conversations; requeued after the loop. + deferred.push(batch); + continue; + } + pool::ClaimOutcome::NoIdleAgent => { + let pending = queue.pending_conversations(); + tracing::debug!(pending_conversations = pending, "pool_exhausted"); + requeue_undispatched_batch(queue, batch); break; } }; - tracing::debug!(agent = agent.index, channel = %channel_id, affinity_hit, "agent_claimed"); + tracing::debug!(agent = agent.index, scope = %scope, affinity_hit, "agent_claimed"); let recoverable_batch = match ctx.dedup_mode { DedupMode::Queue => Some(batch.clone()), @@ -2769,7 +3026,7 @@ fn dispatch_pending( // Goose-native non-cancelling steer seam: snapshot capability before // the agent moves into `run_prompt_task`, and install the per-turn // steer receiver on the read loop so the main loop's mode-gate fork - // (see the `if accepted && queue.is_channel_in_flight(...)` block + // (see the `if accepted && queue.is_scope_in_flight(...)` block // in the relay event branch of the main `select!` loop) can drive // it via the matching sender stored in `TaskMeta.steer_tx`. // Install the steer channel for every prompt task — the supervisor @@ -2802,21 +3059,58 @@ fn dispatch_pending( abort_handle.id(), pool::TaskMeta { agent_index, - channel_id: Some(channel_id), + scope: Some(scope), turn_id, recoverable_batch, control_tx: Some(control_tx), steer_tx, }, ); - dispatched_channels.push((channel_id, typing_scope)); + dispatched_conversations.push((scope, typing_scope)); + } + // Release deferred conversations back to their queue lanes with original + // timestamps (fairness preserved); the next dispatch cycle retries them. + let deferred_count = deferred.len(); + for batch in deferred { + requeue_undispatched_batch(queue, batch); } tracing::debug!( - dispatched = dispatched_channels.len(), - queue_depth = queue.pending_channels(), + dispatched = dispatched_conversations.len(), + deferred = deferred_count, + queue_depth = queue.pending_conversations(), "dispatch_pending" ); - dispatched_channels + dispatched_conversations +} + +/// Return a flushed-but-undispatched batch to the queue without losing state. +/// +/// Regular events go back to the queue front with their original timestamps +/// (fairness preserved, no retry throttle). Any merged cancelled-events +/// carryover is re-stored via `requeue_as_cancelled` so the annotated +/// merged-prompt framing survives the deferral instead of being flattened +/// into regular events. A pure cancelled re-dispatch batch (the `flush_next` +/// fallback: events promoted from the cancelled store, `cancel_reason` set, +/// no fresh events) goes back to the cancelled store wholesale for the same +/// reason. Finally the scope's in-flight slot is released. +fn requeue_undispatched_batch(queue: &mut EventQueue, mut batch: FlushBatch) { + let scope = batch.scope; + if !batch.cancelled_events.is_empty() { + let reason = batch.cancel_reason.unwrap_or(CancelReason::Steer); + let cancelled_carryover = FlushBatch { + scope, + events: std::mem::take(&mut batch.cancelled_events), + cancelled_events: vec![], + cancel_reason: None, + }; + queue.requeue_as_cancelled(cancelled_carryover, reason); + } else if let Some(reason) = batch.cancel_reason { + queue.requeue_as_cancelled(batch, reason); + queue.mark_complete(scope); + return; + } + queue.requeue_preserve_timestamps(batch); + queue.mark_complete(scope); } /// Spawn a task that posts a user-visible failure notice to the relay. @@ -2835,7 +3129,7 @@ fn spawn_failure_notice( .map(|be| queue::parse_thread_tags(&be.event)) .unwrap_or_default(); let rest = rest.clone(); - let channel_id = batch.channel_id; + let channel_id = batch.channel_id(); tokio::spawn(async move { pool::post_failure_notice(&rest, channel_id, &thread_tags, &content).await; }); @@ -2879,7 +3173,7 @@ fn handle_prompt_result( if let Some(batch) = result.batch.take() { // Don't requeue batches for channels the agent was removed from — // those events are stale and should be silently dropped. - if !removed_channels.contains(&batch.channel_id) { + if !removed_channels.contains(&batch.channel_id()) { if matches!( result.outcome, PromptOutcome::Cancelled | PromptOutcome::CancelDrainTimeout(_) @@ -2907,7 +3201,7 @@ fn handle_prompt_result( }) ) { tracing::error!( - channel_id = %batch.channel_id, + scope = %batch.scope, events = batch.events.len(), "dead-lettering batch after hard-cap timeout (no recent activity) — discarding {} events", batch.events.len(), @@ -2925,7 +3219,7 @@ fn handle_prompt_result( }) ) { tracing::warn!( - channel_id = %batch.channel_id, + scope = %batch.scope, events = batch.events.len(), "hard-cap timeout with recent activity — requeueing for retry" ); @@ -2956,7 +3250,7 @@ fn handle_prompt_result( } } else { tracing::debug!( - channel_id = %batch.channel_id, + scope = %batch.scope, events = batch.events.len(), "dropping failed batch for removed channel" ); @@ -2965,7 +3259,7 @@ fn handle_prompt_result( } match &result.source { - PromptSource::Channel(ch) => queue.mark_complete(*ch), + PromptSource::Conversation(scope) => queue.mark_complete(*scope), PromptSource::Heartbeat => *heartbeat_in_flight = false, } @@ -2986,22 +3280,20 @@ fn handle_prompt_result( PromptOutcome::CancelDrainTimeout(_) => "cancel_drain_timeout", }; let agent_index = result.agent.index; - // Capture the spawn-time configured model and our PID before the agent is - // moved into match arms below. `desired_model` reflects the config/persona - // model at spawn time — it does NOT reflect `session/set_model` overrides, - // which live in buzz-agent's session state and are what `llm: (model) …` - // errors carry. The two can legitimately differ; `configured_model=` is - // still valuable for identifying a stale orphan running an old model. + // Capture the spawn-time configured baseline and our PID before the agent + // is moved into match arms below. Runtime channel overrides are deliberately + // excluded: `configured_model=` identifies the persona/config model used by + // this process, while session-level model errors may legitimately differ. let harness_configured_model = result .agent - .desired_model + .baseline_model .as_deref() .unwrap_or("") .to_string(); let harness_pid = std::process::id(); let channel_id = match &result.source { - PromptSource::Channel(ch) => Some(*ch), + PromptSource::Conversation(scope) => Some(scope.channel_id), PromptSource::Heartbeat => None, }; let turn_id = result.turn_id.clone(); @@ -3059,6 +3351,9 @@ fn handle_prompt_result( emit_turn_error(&death_message, None); let index = result.agent.index; + // The process is being replaced — its ACP sessions die with it, + // so conversations it owned must be free to re-own elsewhere. + pool.release_agent_ownerships(index); let slot_history = &mut crash_history[index]; if !spawn_respawn_task( result.agent, @@ -3099,6 +3394,8 @@ fn handle_prompt_result( emit_turn_error(&death_message, None); let index = result.agent.index; + // Same as the fatal-outcome path: the process is being replaced. + pool.release_agent_ownerships(index); let slot_history = &mut crash_history[index]; if !spawn_respawn_task( result.agent, @@ -3164,6 +3461,8 @@ fn handle_prompt_result( emit_turn_error(&e.to_string(), error_code); let index = result.agent.index; + // The process is being replaced — release its ownerships. + pool.release_agent_ownerships(index); let slot_history = &mut crash_history[index]; if !spawn_respawn_task( result.agent, @@ -3203,7 +3502,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<()>, @@ -3215,28 +3514,31 @@ fn recover_panicked_agent( return; }; let i = meta.agent_index; + // The panicked task dropped its AcpClient — the process and its sessions + // are gone, so conversations owned by this slot must re-own elsewhere. + pool.release_agent_ownerships(i); // Requeue BEFORE mark_complete (same rationale as handle_prompt_result). if let Some(batch) = meta.recoverable_batch { - if let Some(ch) = meta.channel_id { - if !removed_channels.contains(&ch) { + if let Some(scope) = meta.scope { + if !removed_channels.contains(&scope.channel_id) { // Dead-letter on exhaustion is logged inside requeue(); a // panic path has no outcome to report, so no notice here. let _ = queue.requeue(batch); tracing::warn!("requeued batch for panicked agent {i}"); } else { tracing::debug!( - channel_id = %ch, + scope = %scope, "dropping panicked batch for removed channel" ); } } } - if let Some(ch) = meta.channel_id { - queue.mark_complete(ch); - typing_channels.remove(&ch); - tracing::warn!("cleared wedged in-flight channel {ch} from panicked agent {i}"); + if let Some(scope) = meta.scope { + queue.mark_complete(scope); + typing_channels.remove(&scope); + tracing::warn!("cleared wedged in-flight conversation {scope} from panicked agent {i}"); } else { *heartbeat_in_flight = false; tracing::warn!("cleared wedged heartbeat_in_flight from panicked agent {i}"); @@ -3246,7 +3548,7 @@ fn recover_panicked_agent( observer.emit( "agent_panic", Some(i), - &observer::context_for(meta.channel_id, None, Some(meta.turn_id)), + &observer::context_for(meta.scope.map(|s| s.channel_id), None, Some(meta.turn_id)), serde_json::json!({ "outcome": "panic", "error": format!("Agent task panicked: {join_error}"), @@ -3301,7 +3603,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<()>, @@ -3339,7 +3641,7 @@ fn dispatch_heartbeat( if *heartbeat_in_flight { return; } - let agent = match pool.try_claim(None) { + let agent = match pool.try_claim_for_heartbeat() { Some(a) => a, None => return, }; @@ -3371,7 +3673,7 @@ fn dispatch_heartbeat( abort_handle.id(), pool::TaskMeta { agent_index, - channel_id: None, + scope: None, turn_id, recoverable_batch: None, control_tx: None, @@ -3967,7 +4269,7 @@ mod owner_control_command_tests { abort_handle.id(), pool::TaskMeta { agent_index: 0, - channel_id: Some(channel_id), + scope: Some(ConversationScope::channel(channel_id)), turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: Some(control_tx), @@ -3977,18 +4279,18 @@ mod owner_control_command_tests { assert!(!signal_in_flight_task( &mut pool, - other_channel_id, + ConversationScope::channel(other_channel_id), ControlSignal::Rotate )); assert!(signal_in_flight_task( &mut pool, - channel_id, + ConversationScope::channel(channel_id), ControlSignal::Rotate )); assert_eq!(control_rx.await.unwrap(), ControlSignal::Rotate); assert!(!signal_in_flight_task( &mut pool, - channel_id, + ConversationScope::channel(channel_id), ControlSignal::Rotate )); } @@ -4593,6 +4895,7 @@ mod error_outcome_emission_tests { .expect("spawn cat as inert agent"), state: Default::default(), model_capabilities: None, + baseline_model: None, desired_model: None, model_overridden: false, agent_name: "unknown".into(), @@ -4618,7 +4921,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -4641,7 +4944,7 @@ mod error_outcome_emission_tests { let result = PromptResult { agent, - source: PromptSource::Channel(Uuid::new_v4()), + source: PromptSource::Conversation(ConversationScope::channel(Uuid::new_v4())), turn_id: "test-turn-id".to_string(), outcome, batch: None, @@ -4694,7 +4997,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: Some(channel_id), + scope: Some(ConversationScope::channel(channel_id)), turn_id: "panic-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -4786,7 +5089,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -4807,7 +5110,7 @@ mod error_outcome_emission_tests { let observer = ObserverHandle::in_process(); let result = PromptResult { agent, - source: PromptSource::Channel(Uuid::new_v4()), + source: PromptSource::Conversation(ConversationScope::channel(Uuid::new_v4())), turn_id: "test-turn-id".to_string(), outcome, batch: None, @@ -4856,7 +5159,7 @@ mod error_outcome_emission_tests { .sign_with_keys(&keys) .unwrap(); FlushBatch { - channel_id: Uuid::new_v4(), + scope: ConversationScope::channel(Uuid::new_v4()), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -4867,9 +5170,9 @@ mod error_outcome_emission_tests { } }; - // Returns (pending_channels, queued_event_count_for_channel). + // Returns (pending_conversations, queued_event_count_for_channel). let run = |outcome: PromptOutcome, batch: FlushBatch| async move { - let channel_id = batch.channel_id; + let channel_id = batch.channel_id(); let agent = dummy_agent(0).await; let mut pool = AgentPool::from_slots(vec![None]); let task_id = pool.join_set.spawn(async {}).id(); @@ -4877,7 +5180,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -4897,7 +5200,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::Conversation(ConversationScope::channel(channel_id)), turn_id: "test-turn-id".to_string(), outcome, batch: Some(batch), @@ -4916,8 +5219,8 @@ mod error_outcome_emission_tests { None, ); ( - queue.pending_channels(), - queue.queued_event_count(&channel_id), + queue.pending_conversations(), + queue.queued_event_count(&ConversationScope::channel(channel_id)), ) }; @@ -4962,7 +5265,7 @@ mod error_outcome_emission_tests { .sign_with_keys(&keys) .unwrap(); FlushBatch { - channel_id, + scope: ConversationScope::channel(channel_id), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -4974,7 +5277,7 @@ mod error_outcome_emission_tests { }; let run = |outcome: PromptOutcome, batch: FlushBatch| async move { - let channel_id = batch.channel_id; + let channel_id = batch.channel_id(); let agent = dummy_agent(0).await; let mut pool = AgentPool::from_slots(vec![None]); let task_id = pool.join_set.spawn(async {}).id(); @@ -4982,7 +5285,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -5002,7 +5305,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::Conversation(ConversationScope::channel(channel_id)), turn_id: "test-turn-id".to_string(), outcome, batch: Some(batch), @@ -5021,8 +5324,8 @@ mod error_outcome_emission_tests { None, ); ( - queue.pending_channels(), - queue.queued_event_count(&channel_id), + queue.pending_conversations(), + queue.queued_event_count(&ConversationScope::channel(channel_id)), ) }; @@ -5058,7 +5361,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -5078,7 +5381,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let observer = ObserverHandle::in_process(); let batch = FlushBatch { - channel_id, + scope: ConversationScope::channel(channel_id), events: vec![BatchEvent { event: EventBuilder::new(Kind::Custom(9), "test") .sign_with_keys(&Keys::generate()) @@ -5091,7 +5394,7 @@ mod error_outcome_emission_tests { }; let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Conversation(ConversationScope::channel(channel_id)), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::Timeout(TimeoutKind::Hard { recently_active: true, @@ -5125,7 +5428,7 @@ mod error_outcome_emission_tests { ), ); assert_eq!( - queue.pending_channels(), + queue.pending_conversations(), 1, "batch must be requeued, not dead-lettered, while within the retry budget" ); @@ -5143,7 +5446,10 @@ mod error_outcome_emission_tests { // Simulate MAX_RETRIES prior failed attempts on this channel so the // upcoming requeue() call in handle_prompt_result crosses the // dead-letter threshold. - queue.set_retry_count_for_test(channel_id, crate::queue::MAX_RETRIES); + queue.set_retry_count_for_test( + ConversationScope::channel(channel_id), + crate::queue::MAX_RETRIES, + ); let agent = dummy_agent(0).await; let mut pool = AgentPool::from_slots(vec![None]); @@ -5152,7 +5458,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -5171,7 +5477,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let observer = ObserverHandle::in_process(); let batch = FlushBatch { - channel_id, + scope: ConversationScope::channel(channel_id), events: vec![BatchEvent { event: EventBuilder::new(Kind::Custom(9), "final-attempt") .sign_with_keys(&Keys::generate()) @@ -5184,7 +5490,7 @@ mod error_outcome_emission_tests { }; let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Conversation(ConversationScope::channel(channel_id)), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::Timeout(TimeoutKind::Hard { recently_active: true, @@ -5218,7 +5524,7 @@ mod error_outcome_emission_tests { ), ); assert_eq!( - queue.queued_event_count(&channel_id), + queue.queued_event_count(&ConversationScope::channel(channel_id)), 0, "batch with an exhausted retry budget must be dead-lettered, not requeued" ); @@ -5251,7 +5557,7 @@ mod error_outcome_emission_tests { ); let channel_id = Uuid::new_v4(); let batch = FlushBatch { - channel_id, + scope: ConversationScope::channel(channel_id), events: vec![BatchEvent { event: original_event.clone(), prompt_tag: "test".into(), @@ -5268,7 +5574,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -5281,7 +5587,7 @@ mod error_outcome_emission_tests { // out on drain — so it is already queued by the time // handle_prompt_result runs. queue.push(QueuedEvent { - channel_id, + scope: ConversationScope::channel(channel_id), event: new_event.clone(), received_at: std::time::Instant::now(), prompt_tag: "test".into(), @@ -5300,7 +5606,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::Conversation(ConversationScope::channel(channel_id)), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::CancelDrainTimeout(grace), batch: Some(batch), @@ -5407,7 +5713,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -5429,7 +5735,7 @@ mod error_outcome_emission_tests { let grace = std::time::Duration::from_secs(5); let result = PromptResult { agent, - source: PromptSource::Channel(Uuid::new_v4()), + source: PromptSource::Conversation(ConversationScope::channel(Uuid::new_v4())), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::CancelDrainTimeout(grace), // Explicit Stop already dropped the batch upstream in @@ -5454,7 +5760,7 @@ mod error_outcome_emission_tests { // No batch to merge — the queue has nothing pending for any channel. assert_eq!( - queue.pending_channels(), + queue.pending_conversations(), 0, "a dropped Stop batch must not leave anything queued" ); @@ -5755,3 +6061,817 @@ mod observer_payload_trim_tests { assert!(leaf.contains("[elided")); } } + +#[cfg(test)] +mod conversation_scope_routing_tests { + use super::*; + use crate::pool::{AgentPool, OwnedAgent}; + use nostr::EventId; + + fn thread_scope(channel_id: Uuid, root_byte: char) -> ConversationScope { + let root = EventId::from_hex(&root_byte.to_string().repeat(64)).expect("valid hex"); + ConversationScope::thread(channel_id, root) + } + + async fn dummy_agent(index: usize) -> OwnedAgent { + OwnedAgent { + index, + acp: AcpClient::spawn("cat", &[], &[], false) + .await + .expect("spawn cat as inert agent"), + state: Default::default(), + model_capabilities: None, + baseline_model: None, + desired_model: None, + model_overridden: false, + agent_name: "unknown".into(), + goose_system_prompt_supported: None, + protocol_version: 1, + } + } + + async fn dummy_agent_with_baseline(index: usize, baseline: &str) -> OwnedAgent { + let mut agent = dummy_agent(index).await; + agent.baseline_model = Some(baseline.to_string()); + agent.desired_model = Some(baseline.to_string()); + agent + } + + fn insert_task( + pool: &mut AgentPool, + agent_index: usize, + scope: ConversationScope, + ) -> tokio::sync::oneshot::Receiver { + let (control_tx, control_rx) = tokio::sync::oneshot::channel(); + let abort_handle = pool.join_set.spawn(async {}); + pool.task_map_mut().insert( + abort_handle.id(), + pool::TaskMeta { + agent_index, + scope: Some(scope), + turn_id: format!("turn-{agent_index}"), + recoverable_batch: None, + control_tx: Some(control_tx), + steer_tx: None, + }, + ); + control_rx + } + + /// With two threads of the SAME channel in flight simultaneously, a + /// control signal for one thread must not reach the other — and a + /// channel-scope signal must not reach either thread. + #[tokio::test] + async fn control_signal_targets_only_the_matching_thread() { + let mut pool = AgentPool::from_slots(vec![]); + let ch = Uuid::new_v4(); + let thread_a = thread_scope(ch, 'a'); + let thread_b = thread_scope(ch, 'b'); + + let rx_a = insert_task(&mut pool, 0, thread_a); + let mut rx_b = insert_task(&mut pool, 1, thread_b); + + // Channel-scope signal: no channel-level turn is in flight → no-op. + assert!(!signal_in_flight_task( + &mut pool, + ConversationScope::channel(ch), + ControlSignal::Cancel + )); + + // Thread A signal reaches only thread A's task. + assert!(signal_in_flight_task( + &mut pool, + thread_a, + ControlSignal::Cancel + )); + assert_eq!(rx_a.await.unwrap(), ControlSignal::Cancel); + assert!( + rx_b.try_recv().is_err(), + "thread B's control channel must be untouched" + ); + + // Thread B can still be rotated independently afterwards. + assert!(signal_in_flight_task( + &mut pool, + thread_b, + ControlSignal::Rotate + )); + assert_eq!(rx_b.await.unwrap(), ControlSignal::Rotate); + } + + /// Desktop observer frames are channel-addressed: they fan out to every + /// in-flight conversation under the channel, but never cross channels. + #[tokio::test] + async fn channel_wide_signal_fans_out_within_the_channel_only() { + let mut pool = AgentPool::from_slots(vec![]); + let ch = Uuid::new_v4(); + let other = Uuid::new_v4(); + + let rx_a = insert_task(&mut pool, 0, thread_scope(ch, 'a')); + let rx_b = insert_task(&mut pool, 1, ConversationScope::channel(ch)); + let mut rx_other = insert_task(&mut pool, 2, ConversationScope::channel(other)); + + let fired = signal_in_flight_tasks_for_channel(&mut pool, ch, ControlSignal::Cancel); + assert_eq!(fired, 2, "both conversations under the channel signalled"); + assert_eq!(rx_a.await.unwrap(), ControlSignal::Cancel); + assert_eq!(rx_b.await.unwrap(), ControlSignal::Cancel); + assert!( + rx_other.try_recv().is_err(), + "other channel's task must be untouched" + ); + } + + /// Idle `!rotate` invalidates only the targeted conversation's session; + /// sibling threads and the channel session survive. Channel removal + /// sweeps them all. + #[tokio::test] + async fn idle_rotation_and_channel_removal_scope_correctly() { + let ch = Uuid::new_v4(); + let thread_a = thread_scope(ch, 'a'); + let thread_b = thread_scope(ch, 'b'); + let channel = ConversationScope::channel(ch); + + let mut agent = dummy_agent(0).await; + agent.state.sessions.insert(thread_a, "sess-a".into()); + agent.state.sessions.insert(thread_b, "sess-b".into()); + agent.state.sessions.insert(channel, "sess-ch".into()); + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + + assert!(pool.has_session_for(thread_a)); + assert_eq!(pool.invalidate_scope_sessions(thread_a), 1); + assert!(!pool.has_session_for(thread_a)); + assert!(pool.has_session_for(thread_b), "sibling thread survives"); + assert!(pool.has_session_for(channel), "channel session survives"); + + // Membership removal: every remaining scope under the channel goes. + assert_eq!(pool.invalidate_channel_sessions(ch), 1); + assert!(!pool.has_session_for(thread_b)); + assert!(!pool.has_session_for(channel)); + } + + /// Agent affinity is per-conversation: a claim for thread B prefers the + /// agent holding thread B's session even when another idle agent holds a + /// session for the same channel's other thread. + #[tokio::test] + async fn try_claim_prefers_the_agent_with_the_thread_session() { + let ch = Uuid::new_v4(); + let thread_a = thread_scope(ch, 'a'); + let thread_b = thread_scope(ch, 'b'); + + let mut agent0 = dummy_agent(0).await; + agent0.state.sessions.insert(thread_a, "sess-a".into()); + let mut agent1 = dummy_agent(1).await; + agent1.state.sessions.insert(thread_b, "sess-b".into()); + let mut pool = AgentPool::from_slots(vec![Some(agent0), Some(agent1)]); + + let claimed = match pool.try_claim_for_scope(thread_b) { + pool::ClaimOutcome::Claimed(a) => *a, + _ => panic!("an agent must be claimable for thread B"), + }; + assert_eq!(claimed.index, 1, "affinity must follow the thread session"); + pool.return_agent(claimed); + } + + /// DM channels and unknown channels stay channel-scoped; regular channels + /// opt in to thread scoping. + #[test] + fn thread_scoping_gate_respects_channel_type() { + let dm = Uuid::new_v4(); + let regular = Uuid::new_v4(); + let unknown = Uuid::new_v4(); + let mut info = HashMap::new(); + info.insert( + dm, + relay::ChannelInfo { + name: "dm".into(), + channel_type: "dm".into(), + }, + ); + info.insert( + regular, + relay::ChannelInfo { + name: "general".into(), + channel_type: "channel".into(), + }, + ); + + assert!(!channel_supports_thread_scoping(&info, dm)); + assert!(channel_supports_thread_scoping(&info, regular)); + assert!( + !channel_supports_thread_scoping(&info, unknown), + "unknown channel type must fall back to channel scope" + ); + } + + fn make_test_prompt_context() -> PromptContext { + let agent_keys = nostr::Keys::generate(); + PromptContext { + mcp_servers: vec![], + initial_message: None, + idle_timeout: Duration::from_secs(60), + max_turn_duration: Duration::from_secs(120), + turn_liveness_interval: Duration::ZERO, + dedup_mode: DedupMode::Queue, + system_prompt: None, + team_instructions: None, + heartbeat_prompt: None, + base_prompt: None, + cwd: ".".to_string(), + rest_client: relay::RestClient { + http: reqwest::Client::new(), + base_url: "http://127.0.0.1:0".to_string(), + keys: agent_keys.clone(), + auth_tag_json: None, + }, + channel_info: std::sync::RwLock::new(HashMap::new()), + context_message_limit: 0, + max_turns_per_session: 0, + permission_mode: config::PermissionMode::Default, + agent_keys, + agent_owner_pubkey: None, + memory_enabled: false, + harness_name: "goose".to_string(), + } + } + + fn make_stream_event(content: &str) -> nostr::Event { + let keys = nostr::Keys::generate(); + nostr::EventBuilder::new(nostr::Kind::Custom(9), content) + .tags([]) + .sign_with_keys(&keys) + .expect("sign test event") + } + + /// Finding 1 (pool level): while the agent owning scope A is checked out + /// on scope B, no fallback agent may claim A — it must wait for its + /// owner; once the owner returns, A dispatches to that same agent. + #[tokio::test] + async fn owner_busy_scope_waits_for_owner_instead_of_forking() { + let ch = Uuid::new_v4(); + let scope_a = thread_scope(ch, 'a'); + let scope_b = thread_scope(ch, 'b'); + let agent0 = dummy_agent(0).await; + let agent1 = dummy_agent(1).await; + let mut pool = AgentPool::from_slots(vec![Some(agent0), Some(agent1)]); + + // Agent 0 becomes A's owner and holds a live session for it. + let mut a0 = match pool.try_claim_for_scope(scope_a) { + pool::ClaimOutcome::Claimed(a) => *a, + _ => panic!("first claim must succeed"), + }; + assert_eq!(a0.index, 0); + a0.state.sessions.insert(scope_a, "sess-a".into()); + pool.return_agent(a0); + assert_eq!(pool.scope_owner(&scope_a), Some(0)); + + // Agent 0 checks out on unrelated scope B (task registered, as + // dispatch would do). + let a0 = match pool.try_claim_for_scope(scope_b) { + pool::ClaimOutcome::Claimed(a) => *a, + _ => panic!("claim for B must succeed"), + }; + assert_eq!(a0.index, 0, "unowned scope claims the first idle agent"); + let _rx_b = insert_task(&mut pool, 0, scope_b); + + // The exact reviewed scenario: A's owner is busy; agent 1 is idle + // and must NOT be claimed for A. + assert!(matches!( + pool.try_claim_for_scope(scope_a), + pool::ClaimOutcome::OwnerBusy + )); + assert!( + pool.any_idle(), + "the idle fallback agent was left untouched" + ); + assert_eq!( + pool.scope_owner(&scope_a), + Some(0), + "ownership must not migrate while the owner is alive" + ); + + // Owner returns → A goes back to the SAME agent (session intact). + pool.task_map_mut().retain(|_, m| m.agent_index != 0); + pool.return_agent(a0); + let a = match pool.try_claim_for_scope(scope_a) { + pool::ClaimOutcome::Claimed(a) => *a, + _ => panic!("owner idle again — claim must succeed"), + }; + assert_eq!(a.index, 0); + assert_eq!( + a.state.sessions.get(&scope_a).map(String::as_str), + Some("sess-a") + ); + pool.return_agent(a); + } + + /// Finding 1 (dispatch level): the full dispatch path defers an + /// owner-busy conversation — the event stays queued, no task is spawned + /// on the idle fallback agent — and dispatches it to the owner once the + /// owner returns. + #[tokio::test] + async fn dispatch_defers_owner_busy_conversation_without_fallback() { + let ctx = Arc::new(make_test_prompt_context()); + let ch = Uuid::new_v4(); + let scope_a = thread_scope(ch, 'a'); + let scope_b = thread_scope(ch, 'b'); + let agent0 = dummy_agent(0).await; + let agent1 = dummy_agent(1).await; + let mut pool = AgentPool::from_slots(vec![Some(agent0), Some(agent1)]); + + // Agent 0 owns A (live session) and is checked out on B. + let mut a0 = match pool.try_claim_for_scope(scope_a) { + pool::ClaimOutcome::Claimed(a) => *a, + _ => panic!("claim A"), + }; + a0.state.sessions.insert(scope_a, "sess-a".into()); + pool.return_agent(a0); + let a0 = match pool.try_claim_for_scope(scope_b) { + pool::ClaimOutcome::Claimed(a) => *a, + _ => panic!("claim B"), + }; + assert_eq!(a0.index, 0); + let _rx_b = insert_task(&mut pool, 0, scope_b); + let tasks_before = pool.task_map().len(); + + let mut queue = EventQueue::new(config::DedupMode::Queue); + queue.push(QueuedEvent { + scope: scope_a, + event: make_stream_event("reply in thread A"), + received_at: std::time::Instant::now(), + prompt_tag: "test".into(), + }); + + let dispatched = dispatch_pending(&mut pool, &mut queue, &ctx); + assert!(dispatched.is_empty(), "owner-busy scope must not dispatch"); + assert_eq!( + queue.queued_event_count(&scope_a), + 1, + "the event must remain queued for the owner" + ); + assert!(pool.any_idle(), "no fallback agent was claimed for A"); + assert_eq!( + pool.task_map().len(), + tasks_before, + "no new task was spawned for A" + ); + + // Owner returns → the deferred conversation dispatches to agent 0. + pool.task_map_mut().retain(|_, m| m.agent_index != 0); + pool.return_agent(a0); + let dispatched = dispatch_pending(&mut pool, &mut queue, &ctx); + assert_eq!(dispatched.len(), 1); + assert_eq!(dispatched[0].0, scope_a); + assert!( + pool.task_map() + .values() + .any(|m| m.agent_index == 0 && m.scope == Some(scope_a)), + "the deferred conversation must run on its owning agent" + ); + } + + /// Finding 1: a dead owner (slot neither idle nor checked out) releases + /// the scope, which re-owns on another idle agent with a fresh history. + #[tokio::test] + async fn dead_owner_releases_scope_to_another_agent() { + let ch = Uuid::new_v4(); + let scope_a = thread_scope(ch, 'a'); + let agent0 = dummy_agent(0).await; + let agent1 = dummy_agent(1).await; + let mut pool = AgentPool::from_slots(vec![Some(agent0), Some(agent1)]); + + let mut a0 = match pool.try_claim_for_scope(scope_a) { + pool::ClaimOutcome::Claimed(a) => *a, + _ => panic!("claim A"), + }; + a0.state.sessions.insert(scope_a, "sess-a".into()); + // Agent 0 dies while checked out: no return, no task_map entry — + // exactly the state after handle_prompt_result strips the task and + // hands the agent to the respawn path. + pool.release_agent_ownerships(0); + drop(a0); + + let a = match pool.try_claim_for_scope(scope_a) { + pool::ClaimOutcome::Claimed(a) => *a, + _ => panic!("scope must re-own on the surviving agent"), + }; + assert_eq!(a.index, 1); + assert_eq!(pool.scope_owner(&scope_a), Some(1)); + pool.return_agent(a); + } + + /// Finding 1: ownership is released when the owner returns without a + /// live session for the scope (rotation / invalidation during the turn), + /// so the next event may be served by any agent. + #[tokio::test] + async fn ownership_released_when_session_dropped_during_turn() { + let ch = Uuid::new_v4(); + let scope_a = thread_scope(ch, 'a'); + let agent0 = dummy_agent(0).await; + let mut pool = AgentPool::from_slots(vec![Some(agent0)]); + + let a0 = match pool.try_claim_for_scope(scope_a) { + pool::ClaimOutcome::Claimed(a) => *a, + _ => panic!("claim A"), + }; + assert_eq!(pool.scope_owner(&scope_a), Some(0)); + // Turn ends with the session rotated away (no session for A). + pool.return_agent(a0); + assert_eq!( + pool.scope_owner(&scope_a), + None, + "no history left to protect — ownership must be released" + ); + } + + /// Finding 2 (all idle): a desktop channel-level model switch must reach + /// EVERY idle agent holding sessions under the channel, invalidating all + /// of them — and leave other channels' sessions alone. + #[tokio::test] + async fn channel_model_switch_updates_every_idle_session_holder() { + let ch = Uuid::new_v4(); + let other_ch = Uuid::new_v4(); + let thread_a = thread_scope(ch, 'a'); + let channel = ConversationScope::channel(ch); + let other_scope = ConversationScope::channel(other_ch); + + let mut agent0 = dummy_agent_with_baseline(0, "model-base").await; + agent0.state.sessions.insert(thread_a, "sess-a".into()); + let mut agent1 = dummy_agent_with_baseline(1, "model-base").await; + agent1.state.sessions.insert(channel, "sess-ch".into()); + agent1 + .state + .sessions + .insert(other_scope, "sess-other".into()); + let mut pool = AgentPool::from_slots(vec![Some(agent0), Some(agent1)]); + + let result = pool.set_channel_desired_model(ch, "model-x"); + assert_eq!(result, IdleSwitchResult::Switched); + + for slot in pool.agents_mut().iter() { + let agent = slot.as_ref().expect("both agents idle"); + assert_eq!( + agent.desired_model.as_deref(), + Some("model-base"), + "idle agent {} must remain on its explicit baseline", + agent.index + ); + assert!(!agent.model_overridden); + assert!( + !agent.state.sessions.keys().any(|s| s.channel_id == ch), + "agent {}'s sessions under the channel must be invalidated", + agent.index + ); + } + // The unrelated channel's session survives. + let agent1 = pool.agents_mut()[1].as_ref().expect("idle"); + assert_eq!( + agent1.state.sessions.get(&other_scope).map(String::as_str), + Some("sess-other") + ); + + // A claim under the switched channel resolves the override; a claim + // under an unrelated channel resolves the baseline. + let switched = match pool.try_claim_for_scope(thread_a) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("switched channel must be claimable"), + }; + assert_eq!(switched.desired_model.as_deref(), Some("model-x")); + assert!(switched.model_overridden); + pool.return_agent(switched); + + let unrelated = match pool.try_claim_for_scope(other_scope) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("unrelated channel must be claimable"), + }; + assert_eq!(unrelated.desired_model.as_deref(), Some("model-base")); + assert!(!unrelated.model_overridden); + pool.return_agent(unrelated); + } + + /// A channel override is never sticky agent-global state: after serving + /// switched channel A, the same worker resolves channel B (which has no + /// override) back to its configured baseline before session creation. + #[tokio::test] + async fn switched_channel_then_unrelated_channel_restores_baseline() { + let channel_a = Uuid::new_v4(); + let channel_b = Uuid::new_v4(); + let scope_a = ConversationScope::channel(channel_a); + let scope_b = ConversationScope::channel(channel_b); + let agent = dummy_agent_with_baseline(0, "model-base").await; + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + + assert_eq!( + pool.set_channel_desired_model(channel_a, "model-a"), + IdleSwitchResult::NoIdleAgent + ); + let mut claimed_a = match pool.try_claim_for_scope(scope_a) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("channel A must be claimable"), + }; + assert_eq!(claimed_a.desired_model.as_deref(), Some("model-a")); + assert!(claimed_a.model_overridden); + claimed_a.state.sessions.insert(scope_a, "sess-a".into()); + pool.return_agent(claimed_a); + + let claimed_b = match pool.try_claim_for_scope(scope_b) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("channel B must reuse the only worker"), + }; + assert_eq!(claimed_b.index, 0); + assert_eq!( + claimed_b.desired_model.as_deref(), + Some("model-base"), + "a new B session must apply the configured baseline, not A's override" + ); + assert!(!claimed_b.model_overridden); + assert_eq!( + claimed_b.state.sessions.get(&scope_a).map(String::as_str), + Some("sess-a"), + "resolving B must not mutate A's valid session" + ); + pool.return_agent(claimed_b); + } + + /// Distinct channel overrides alternate safely on one worker. Each claim + /// resolves its own channel model and neither switch invalidates the other + /// channel's already-correct session. + #[tokio::test] + async fn distinct_channel_overrides_alternate_on_one_agent() { + let channel_a = Uuid::new_v4(); + let channel_b = Uuid::new_v4(); + let scope_a = ConversationScope::channel(channel_a); + let scope_b = ConversationScope::channel(channel_b); + let agent = dummy_agent_with_baseline(0, "model-base").await; + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + + pool.set_channel_desired_model(channel_a, "model-a"); + let mut claimed_a = match pool.try_claim_for_scope(scope_a) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("channel A must be claimable"), + }; + assert_eq!(claimed_a.desired_model.as_deref(), Some("model-a")); + claimed_a.state.sessions.insert(scope_a, "sess-a".into()); + pool.return_agent(claimed_a); + + pool.set_channel_desired_model(channel_b, "model-b"); + assert!(pool.has_session_for(scope_a), "B switch must preserve A"); + let mut claimed_b = match pool.try_claim_for_scope(scope_b) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("channel B must reuse the only worker"), + }; + assert_eq!(claimed_b.desired_model.as_deref(), Some("model-b")); + assert!(claimed_b.model_overridden); + claimed_b.state.sessions.insert(scope_b, "sess-b".into()); + pool.return_agent(claimed_b); + + let claimed_a_again = match pool.try_claim_for_scope(scope_a) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("channel A must be reclaimable"), + }; + assert_eq!(claimed_a_again.desired_model.as_deref(), Some("model-a")); + assert!(claimed_a_again.model_overridden); + assert_eq!( + claimed_a_again + .state + .sessions + .get(&scope_b) + .map(String::as_str), + Some("sess-b"), + "alternating back to A must preserve B's session" + ); + pool.return_agent(claimed_a_again); + } + + /// Finding 2 (mixed active/idle): the desktop control frame signals the + /// active conversation, updates the idle holder, and any agent claiming + /// a conversation under the channel afterwards inherits the model. + #[tokio::test] + async fn channel_model_switch_covers_active_idle_and_future_claims() { + let ch = Uuid::new_v4(); + let thread_a = thread_scope(ch, 'a'); + let thread_b = thread_scope(ch, 'b'); + let thread_c = thread_scope(ch, 'c'); + let channel = ConversationScope::channel(ch); + + // Agent 0 active on thread A; agent 1 idle holding the channel + // session; agent 2 idle with no sessions (future claimer). + let mut agent0 = dummy_agent_with_baseline(0, "model-base").await; + agent0.state.sessions.insert(thread_a, "sess-a".into()); + agent0.state.sessions.insert(thread_c, "sess-c".into()); + let mut agent1 = dummy_agent_with_baseline(1, "model-base").await; + agent1.state.sessions.insert(channel, "sess-ch".into()); + let agent2 = dummy_agent_with_baseline(2, "model-base").await; + let mut pool = AgentPool::from_slots(vec![Some(agent0), Some(agent1), Some(agent2)]); + let mut active_agent = match pool.try_claim_for_scope(thread_a) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("thread A must be claimable"), + }; + let mut rx_a = insert_task(&mut pool, 0, thread_a); + + let payload = serde_json::json!({ + "type": "switch_model", + "channelId": ch.to_string(), + "modelId": "model-x", + }); + handle_switch_model_control(&payload, &mut pool, None); + + // Active conversation received the switch signal. + assert_eq!( + rx_a.try_recv().expect("active task must be signalled"), + ControlSignal::SwitchModel("model-x".to_string()) + ); + // Idle holder invalidated but remains on its baseline while idle. + let a1 = pool.agents_mut()[1].as_ref().expect("idle"); + assert_eq!(a1.desired_model.as_deref(), Some("model-base")); + assert!(!a1.model_overridden); + assert!(!a1.state.sessions.contains_key(&channel)); + + // Model the active task's SwitchModel arm, then return it. Generation + // reconciliation must also invalidate thread C, which was cached in + // the checked-out holder but was not itself in flight. + active_agent.desired_model = Some("model-x".to_string()); + active_agent.model_overridden = true; + active_agent.state.invalidate_scope(&thread_a); + pool.task_map_mut().retain(|_, meta| meta.agent_index != 0); + pool.return_agent(active_agent); + let a0 = pool.agents_mut()[0].as_ref().expect("returned idle"); + assert!(!a0.state.sessions.contains_key(&thread_c)); + assert_eq!(a0.desired_model.as_deref(), Some("model-base")); + + // A future claim under the channel inherits the desired model. + let claimed = match pool.try_claim_for_scope(thread_b) { + pool::ClaimOutcome::Claimed(a) => *a, + _ => panic!("an idle agent must be claimable"), + }; + assert_eq!(claimed.desired_model.as_deref(), Some("model-x")); + assert!(claimed.model_overridden); + pool.return_agent(claimed); + } + + /// Scope derivation uses cached channel type correctly: DMs stay + /// channel-scoped and regular/forum channels thread-scope. Unknown types + /// fail closed to channel scope; the dynamic-subscription gate below + /// prevents joined-channel events from being delivered in that state. + #[test] + fn dynamic_channel_metadata_enables_scoping_without_restart() { + let ctx = make_test_prompt_context(); + let dm = Uuid::new_v4(); + let regular = Uuid::new_v4(); + let forum = Uuid::new_v4(); + + let reply_tags = |root: char| { + vec![vec![ + "e".to_string(), + root.to_string().repeat(64), + String::new(), + "root".to_string(), + ]] + }; + let make_reply = |root: char| { + let keys = nostr::Keys::generate(); + let tags: Vec = reply_tags(root) + .into_iter() + .map(|t| nostr::Tag::parse(&t).expect("tag")) + .collect(); + nostr::EventBuilder::new(nostr::Kind::Custom(9), "reply") + .tags(tags) + .sign_with_keys(&keys) + .expect("sign") + }; + let scoping = |ctx: &PromptContext, ch: Uuid| { + let info = ctx + .channel_info + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + channel_supports_thread_scoping(&info, ch) + }; + + // The low-level resolver fails closed for unknown metadata. Production + // dynamic subscriptions remain closed here rather than delivering. + for ch in [dm, regular, forum] { + assert!(!scoping(&ctx, ch)); + let scope = ConversationScope::for_event(ch, &make_reply('a'), scoping(&ctx, ch)); + assert_eq!(scope, ConversationScope::channel(ch)); + } + + // Metadata resolves (what `refresh_channel_info` / the per-prompt + // lazy fetch cache on success). + for (ch, ty) in [(dm, "dm"), (regular, "channel"), (forum, "forum")] { + pool::cache_channel_info( + &ctx, + ch, + &queue::PromptChannelInfo { + name: "dynamic".into(), + channel_type: ty.into(), + }, + ); + } + + // DM stays channel-scoped; regular and forum channels thread-scope. + assert!(!scoping(&ctx, dm)); + let dm_scope = ConversationScope::for_event(dm, &make_reply('a'), scoping(&ctx, dm)); + assert_eq!(dm_scope, ConversationScope::channel(dm)); + for ch in [regular, forum] { + assert!(scoping(&ctx, ch)); + let scope = ConversationScope::for_event(ch, &make_reply('a'), scoping(&ctx, ch)); + assert!( + scope.is_thread(), + "resolved non-DM channel must thread-scope" + ); + } + } + + /// Regression for the membership-add event-before-metadata race. The + /// subscription-ready signal is the production delivery gate: it cannot + /// fire until metadata is cached, so an event arriving immediately when + /// the subscription opens and a later event resolve to the same thread + /// scope and therefore the same cached ACP session. + #[tokio::test] + async fn dynamic_subscription_gates_first_event_on_metadata() { + let ctx = Arc::new(make_test_prompt_context()); + let channel_id = Uuid::new_v4(); + let membership_event_id = "membership-generation".to_string(); + let (attempt_tx, mut attempt_rx) = mpsc::unbounded_channel(); + let (ready_tx, mut ready_rx) = mpsc::unbounded_channel(); + let release_metadata = Arc::new(tokio::sync::Semaphore::new(0)); + let resolver_ctx = Arc::clone(&ctx); + let resolver_release = Arc::clone(&release_metadata); + let ready = DynamicChannelSubscriptionReady { + channel_id, + membership_event_id: membership_event_id.clone(), + replay_since: 42, + }; + + let resolver = tokio::spawn(resolve_metadata_before_dynamic_subscribe( + move || { + let attempt_tx = attempt_tx.clone(); + let resolver_ctx = Arc::clone(&resolver_ctx); + let resolver_release = Arc::clone(&resolver_release); + async move { + let _ = attempt_tx.send(()); + let permit = resolver_release + .acquire() + .await + .expect("metadata gate remains open"); + permit.forget(); + pool::cache_channel_info( + &resolver_ctx, + channel_id, + &queue::PromptChannelInfo { + name: "dynamic-forum".into(), + channel_type: "forum".into(), + }, + ); + true + } + }, + ready, + ready_tx, + Duration::from_secs(60), + )); + + attempt_rx.recv().await.expect("metadata lookup started"); + assert!( + matches!(ready_rx.try_recv(), Err(mpsc::error::TryRecvError::Empty)), + "event delivery must remain closed while metadata is unresolved" + ); + + // Metadata resolves; only then may the main loop subscribe and expose + // the first replayed/live event. + release_metadata.add_permits(1); + let delivered_gate = ready_rx.recv().await.expect("subscription becomes ready"); + assert_eq!(delivered_gate.membership_event_id, membership_event_id); + + let make_reply = || { + let keys = nostr::Keys::generate(); + let root = "d".repeat(64); + let tag = + nostr::Tag::parse(&["e".to_string(), root, String::new(), "root".to_string()]) + .expect("root tag"); + nostr::EventBuilder::new(nostr::Kind::Custom(45003), "comment") + .tags([tag]) + .sign_with_keys(&keys) + .expect("sign comment") + }; + + let first_scope = conversation_scope_for_event(&ctx, channel_id, &make_reply()); + assert!( + first_scope.is_thread(), + "the first event must immediately use resolved forum scoping" + ); + let mut state = SessionState::default(); + state + .sessions + .insert(first_scope, "dynamic-session".to_string()); + + let later_scope = conversation_scope_for_event(&ctx, channel_id, &make_reply()); + assert_eq!(later_scope, first_scope); + assert_eq!( + state.sessions.get(&later_scope).map(String::as_str), + Some("dynamic-session"), + "first and later events must route to the same ACP session" + ); + + resolver.await.expect("metadata resolver exits cleanly"); + } +} diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 61ecbff6c..6597cf8cb 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -39,6 +39,7 @@ use crate::queue::{ PromptProfile, PromptProfileLookup, ThreadTags, }; use crate::relay::{ChannelInfo, RestClient}; +use crate::scope::ConversationScope; /// Window within which agent activity before a hard-cap death qualifies /// the turn as "recently active" (eligible for requeue instead of dead-letter). @@ -50,7 +51,8 @@ const RECENT_ACTIVITY_WINDOW: Duration = Duration::from_secs(60); /// Metadata stored per in-flight task for panic recovery. pub struct TaskMeta { pub agent_index: usize, - pub channel_id: Option, + /// Conversation scope of the in-flight prompt. `None` for heartbeats. + pub scope: Option, /// Identifies terminal events when the task panics before returning a result. pub turn_id: String, /// Clone of batch for Queue mode panic recovery. @@ -78,38 +80,43 @@ pub struct AgentModelCapabilities { pub available_models_raw: Option, } -/// Per-channel session IDs and turn counters. +/// Per-conversation session IDs and turn counters. +/// +/// Keyed by [`ConversationScope`]: unthreaded channel traffic and DMs use the +/// channel-level scope; thread replies in regular channels get their own +/// scope (and therefore their own session, turn counter, and cached prompt +/// sections). /// /// Separated from `OwnedAgent` so the state machine is testable without /// spawning a real agent subprocess. #[derive(Default)] pub struct SessionState { - /// channel_id → session_id - pub sessions: HashMap, + /// conversation scope → session_id + pub sessions: HashMap, pub heartbeat_session: Option, - /// Per-channel turn counters for proactive session rotation. + /// Per-conversation turn counters for proactive session rotation. /// Incremented on each successful prompt; reset when the session is rotated. - pub turn_counts: HashMap, + pub turn_counts: HashMap, /// Turn counter for the heartbeat session. pub heartbeat_turn_count: u32, - /// channel_id → rendered NIP-AE core prompt section, populated once at - /// session creation per Tyler's spec (no mid-session refresh). - pub core_sections: HashMap, - /// channel_id → rendered `[Channel Canvas]` metadata section. + /// conversation scope → rendered NIP-AE core prompt section, populated + /// once at session creation per Tyler's spec (no mid-session refresh). + pub core_sections: HashMap, + /// conversation 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, + pub canvas_sections: HashMap, } 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::Conversation(scope) => { + self.invalidate_scope(scope); } PromptSource::Heartbeat => { self.heartbeat_session = None; @@ -118,13 +125,30 @@ impl SessionState { } } - /// Invalidate a single channel's session and turn counter. - /// Returns `true` if the channel had an active session. + /// Invalidate a single conversation's session and turn counter. + /// Returns `true` if the conversation had an active session. + pub fn invalidate_scope(&mut self, scope: &ConversationScope) -> bool { + self.turn_counts.remove(scope); + self.core_sections.remove(scope); + self.canvas_sections.remove(scope); + self.sessions.remove(scope).is_some() + } + + /// Invalidate every conversation under `channel_id` — the channel-level + /// conversation and all of its thread conversations. Returns `true` if at + /// least one active session was removed. + /// + /// Used for channel-wide teardown (membership removal); scope-targeted + /// rotation goes through [`invalidate_scope`](Self::invalidate_scope). 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.sessions.remove(channel_id).is_some() + self.turn_counts.retain(|s, _| s.channel_id != *channel_id); + self.core_sections + .retain(|s, _| s.channel_id != *channel_id); + self.canvas_sections + .retain(|s, _| s.channel_id != *channel_id); + let before = self.sessions.len(); + self.sessions.retain(|s, _| s.channel_id != *channel_id); + self.sessions.len() != before } /// Invalidate all sessions and turn counters (e.g. after agent exit). @@ -138,11 +162,11 @@ impl SessionState { } #[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) + fn has_scope_state(&self, scope: &ConversationScope) -> bool { + self.sessions.contains_key(scope) + || self.turn_counts.contains_key(scope) + || self.core_sections.contains_key(scope) + || self.canvas_sections.contains_key(scope) } } @@ -153,12 +177,17 @@ pub struct OwnedAgent { pub state: SessionState, /// Model catalog from first session/new. None until first session created. pub model_capabilities: Option, - /// Desired model ID (from `Config.model`). Applied after every `session_new_full()`. + /// Configured/default model for this process. This is immutable for the + /// lifetime of the agent and is the fallback whenever a claimed channel + /// has no runtime override. + pub baseline_model: Option, + /// Effective model for the currently claimed conversation (or heartbeat). + /// Re-resolved from the channel override or [`baseline_model`](Self::baseline_model) + /// on every claim before a session can be created. pub desired_model: Option, - /// Whether `desired_model` was set by a live `SwitchModel` control signal - /// (as opposed to being derived from config/persona at spawn). Used by the - /// desktop reader to distinguish a genuine runtime override from a stale - /// session whose persona model was edited. Reset on spawn/restart. + /// Whether the effective model came from a live channel override rather + /// than the configured baseline. Reset whenever the agent becomes idle or + /// is claimed for a channel without an override. pub model_overridden: bool, /// Normalized agent name from initialize (`agentInfo.name`/`serverInfo.name`). pub agent_name: String, @@ -202,6 +231,27 @@ impl OwnedAgent { self.goose_system_prompt_supported, ) } + + fn use_baseline_model(&mut self) { + self.desired_model.clone_from(&self.baseline_model); + self.model_overridden = false; + } + + fn use_channel_model(&mut self, desired_model: Option<&str>) { + match desired_model { + Some(model) => { + self.desired_model = Some(model.to_string()); + self.model_overridden = true; + } + None => self.use_baseline_model(), + } + } +} + +#[derive(Debug, Clone)] +struct ChannelDesiredModel { + model_id: String, + generation: u64, } /// Pool of agents with take-and-return ownership semantics. @@ -215,6 +265,44 @@ pub struct AgentPool { result_rx: mpsc::UnboundedReceiver, pub join_set: JoinSet<()>, task_map: HashMap, + /// Stable conversation → agent-slot ownership. A scope's ACP session + /// history lives inside exactly one agent process, so once a scope is + /// dispatched to a slot, later turns for that scope must go back to the + /// SAME slot — even if that means waiting while it is checked out on + /// other work. Without this, a busy owner would let `try_claim` hand the + /// scope to another idle agent, silently forking the conversation into a + /// second divergent session. + /// + /// Lifecycle: recorded on claim; released when the owning agent returns + /// without a live session for the scope (rotation, invalidation, failed + /// create), when the scope/channel is explicitly invalidated, or when the + /// owning slot dies (respawn/panic — the process and its sessions are + /// gone, so re-owning elsewhere creates a fresh history, not a fork). + scope_owners: HashMap, + /// Channel-level desired-model state from desktop `switch_model` control + /// frames. Applied to every idle session holder at switch time and + /// inherited by any agent later claiming a conversation under the + /// channel, so returning/newly claiming agents converge on the desired + /// model. Runtime-only — never persisted, gone on restart. + channel_desired_models: HashMap, + /// Last channel-model generation reconciled into each agent slot. A + /// switched channel may have cached sessions inside a checked-out agent; + /// comparing generations when it returns guarantees those stale sessions + /// are invalidated before the slot is reused. + slot_channel_model_generations: HashMap<(usize, Uuid), u64>, + next_channel_model_generation: u64, +} + +/// Outcome of [`AgentPool::try_claim`] for a conversation-scoped claim. +pub enum ClaimOutcome { + /// An agent was claimed for the conversation (ownership recorded). + Claimed(Box), + /// The conversation's owning agent is checked out on other work. The + /// caller must leave the conversation queued and retry after the owner + /// returns — claiming a different agent would fork the session history. + OwnerBusy, + /// No idle agent is available at all. + NoIdleAgent, } /// Result returned by a completed prompt task. @@ -228,10 +316,11 @@ pub struct PromptResult { pub batch: Option, } -/// Whether the prompt came from a channel event or a heartbeat. +/// Whether the prompt came from a conversation (channel or thread) event or a +/// heartbeat. #[derive(Debug)] pub enum PromptSource { - Channel(Uuid), + Conversation(ConversationScope), Heartbeat, } @@ -450,8 +539,12 @@ pub struct PromptContext { pub cwd: String, /// REST client for pre-prompt context fetches (thread/DM history). pub rest_client: RestClient, - /// Channel metadata from discovery (name, type). Read-only after startup. - pub channel_info: std::collections::HashMap, + /// Channel metadata cache (name, type). Seeded from startup discovery and + /// updated at runtime. Dynamic channel subscriptions are gated on this + /// cache so their first delivered event already has correct DM/thread + /// scoping; per-prompt lazy fetches remain a defensive fallback. Guarded by + /// a std `RwLock` (never held across await points). + pub channel_info: std::sync::RwLock>, /// Max messages to include in thread/DM context. 0 = disabled. pub context_message_limit: u32, /// Max turns per session before proactive rotation. 0 = disabled. @@ -490,35 +583,109 @@ impl AgentPool { result_rx, join_set: JoinSet::new(), task_map: HashMap::new(), + scope_owners: HashMap::new(), + channel_desired_models: HashMap::new(), + slot_channel_model_generations: HashMap::new(), + next_channel_model_generation: 0, } } - /// Try to claim an idle agent for the given channel (or heartbeat if `None`). - /// - /// Pass 1: prefer an agent that already has a session for `channel_id`. - /// 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 { - let idx = self.agents.iter().position(|slot| { - slot.as_ref() - .map(|a| a.state.sessions.contains_key(&cid)) - .unwrap_or(false) - }); - if let Some(i) = idx { - return self.agents[i].take(); + /// Reconcile channel-model switches that occurred while `agent` was + /// checked out. Only sessions under switched channels are invalidated; + /// unrelated channel sessions remain intact. + fn reconcile_channel_model_generations(&mut self, agent: &mut OwnedAgent) { + for (channel_id, desired) in &self.channel_desired_models { + let key = (agent.index, *channel_id); + if self.slot_channel_model_generations.get(&key).copied() != Some(desired.generation) { + agent.state.invalidate_channel(channel_id); + self.slot_channel_model_generations + .insert(key, desired.generation); } } + } + + /// Drop ownership entries whose sessions were removed while reconciling + /// an idle/returning slot. + fn release_missing_ownerships(&mut self, agent: &OwnedAgent) { + let idx = agent.index; + self.scope_owners + .retain(|scope, owner| *owner != idx || agent.state.sessions.contains_key(scope)); + } - // Pass 2: first idle agent. + /// Whether the given slot is currently checked out on an in-flight task. + fn slot_checked_out(&self, index: usize) -> bool { + self.task_map.values().any(|m| m.agent_index == index) + } + + /// Try to claim an agent for the given conversation. + /// + /// Ownership is stable: if `scope` was previously dispatched to a slot, + /// only that slot may serve it again while its session history is alive. + /// + /// - Owner idle → claim the owner. + /// - Owner checked out on other work → [`ClaimOutcome::OwnerBusy`]; the + /// caller must keep the scope queued until the owner returns. + /// - Owner slot dead (neither idle nor checked out — process died and its + /// sessions with it) → re-own on any idle agent. + /// - Unowned scope → any idle agent, prefering one that already holds a + /// session for the scope (covers ownership rebuilt from state after + /// explicit map cleanup). + /// + /// On every claim, the effective model is resolved afresh: the target + /// channel override when present, otherwise the agent's configured + /// baseline. Pending switch generations are reconciled first so sessions + /// cached inside an agent that was checked out during a switch cannot be + /// reused under the old model. + pub fn try_claim_for_scope(&mut self, scope: ConversationScope) -> ClaimOutcome { + let claim_idx = match self.scope_owners.get(&scope).copied() { + Some(owner) if self.agents.get(owner).is_some_and(|s| s.is_some()) => Some(owner), + Some(owner) if self.slot_checked_out(owner) => return ClaimOutcome::OwnerBusy, + _ => { + // Unowned, or owner slot is dead: prefer an idle agent that + // already holds a session for this scope, else any idle agent. + self.agents + .iter() + .position(|slot| { + slot.as_ref() + .is_some_and(|a| a.state.sessions.contains_key(&scope)) + }) + .or_else(|| self.agents.iter().position(|slot| slot.is_some())) + } + }; + let Some(idx) = claim_idx else { + return ClaimOutcome::NoIdleAgent; + }; + let Some(mut agent) = self.agents[idx].take() else { + return ClaimOutcome::NoIdleAgent; + }; + self.reconcile_channel_model_generations(&mut agent); + self.release_missing_ownerships(&agent); + self.scope_owners.insert(scope, idx); + let channel_model = self + .channel_desired_models + .get(&scope.channel_id) + .map(|desired| desired.model_id.clone()); + agent.use_channel_model(channel_model.as_deref()); + ClaimOutcome::Claimed(Box::new(agent)) + } + + /// Claim any idle agent for a heartbeat (no conversation ownership). + pub fn try_claim_for_heartbeat(&mut self) -> Option { let idx = self.agents.iter().position(|slot| slot.is_some()); - idx.map(|i| self.agents[i].take().unwrap()) + let mut agent = idx.and_then(|i| self.agents[i].take())?; + self.reconcile_channel_model_generations(&mut agent); + self.release_missing_ownerships(&agent); + agent.use_baseline_model(); + Some(agent) } /// Return an agent to its slot after a task completes. - pub fn return_agent(&mut self, agent: OwnedAgent) { + /// + /// Ownership cleanup happens here: any scope owned by this slot whose + /// session no longer exists on the agent (rotated, invalidated, or never + /// created) is released, so the next event for that scope may be served + /// by any agent — there is no history left to protect. + pub fn return_agent(&mut self, mut agent: OwnedAgent) { let idx = agent.index; if self.agents[idx].is_some() { // This is a bug: two tasks returned the same agent index. Log it @@ -530,20 +697,35 @@ impl AgentPool { "BUG: return_agent called for slot {idx} which is already occupied — overwriting" ); } + self.reconcile_channel_model_generations(&mut agent); + self.release_missing_ownerships(&agent); + // An idle agent is not associated with any channel. Reset the + // ephemeral effective selection so no caller can mistake the last + // claimed channel's override for agent-global state. + agent.use_baseline_model(); self.agents[idx] = Some(agent); } + /// Release every conversation ownership held by `index`. + /// + /// Called when the slot's agent process is being replaced (respawn after + /// exit/timeout/transport error, or panic recovery) — its sessions died + /// with the process, so waiting scopes must be free to re-own elsewhere. + pub fn release_agent_ownerships(&mut self, index: usize) { + self.scope_owners.retain(|_, owner| *owner != index); + } + /// Whether any agent is currently idle (sitting in its slot). pub fn any_idle(&self) -> bool { 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: ConversationScope) -> 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) }) } @@ -566,7 +748,7 @@ impl AgentPool { } /// Try to send a goose-native steer request to the in-flight task for - /// `channel_id`. + /// `scope`. /// /// Returns `Ok(())` if the request was accepted by the read loop's /// receiver (capacity-1 mpsc; one slot is the single in-flight steer @@ -583,19 +765,19 @@ impl AgentPool { /// watcher, to close the result-vs-ack race. /// /// Returns `Err(SteerError::PromptCompleted)` if no task is in flight - /// for `channel_id` (the prompt completed between the mode-gate check - /// and this call, or the channel was never in flight). This is + /// for `scope` (the prompt completed between the mode-gate check + /// and this call, or the conversation was never in flight). This is /// semantically a soft no-op — the caller should release any withheld /// event and let normal dispatch handle delivery. pub fn send_steer( &mut self, - channel_id: Uuid, + scope: ConversationScope, request: SteerRequest, ) -> Result<(), SteerError> { let meta = self .task_map .values_mut() - .find(|m| m.channel_id == Some(channel_id)) + .find(|m| m.scope == Some(scope)) .ok_or(SteerError::PromptCompleted)?; let tx = meta .steer_tx @@ -640,14 +822,15 @@ impl AgentPool { &mut self.agents } - /// Remove the session for `channel_id` from all idle agents. + /// Remove every session under `channel_id` (channel-level and thread + /// conversations alike) from all idle agents. /// /// Called when the agent is removed from a channel — stale sessions /// should not be reused. Checked-out agents (in-flight) are not /// modified; their sessions will fail naturally on the next prompt /// if the relay rejects the request. /// - /// Returns the number of sessions invalidated. + /// Returns the number of agents that had at least one session invalidated. pub fn invalidate_channel_sessions(&mut self, channel_id: Uuid) -> usize { let mut count = 0; for slot in &mut self.agents { @@ -657,39 +840,83 @@ impl AgentPool { } } } + // Explicit channel-wide invalidation ends every conversation under + // the channel: release ownerships so future traffic (e.g. after a + // re-add) can be served by any agent, and drop the channel's + // desired-model override. + self.scope_owners + .retain(|scope, _| scope.channel_id != channel_id); + self.channel_desired_models.remove(&channel_id); + self.slot_channel_model_generations + .retain(|(_, channel), _| *channel != channel_id); + count + } + + /// Remove the session for a single conversation `scope` from all idle + /// agents, leaving sibling conversations in the same channel untouched. + /// + /// Used by the idle `!rotate` path so rotating one thread (or the + /// channel-level conversation) never destroys unrelated thread sessions. + /// + /// Returns the number of sessions invalidated. + pub fn invalidate_scope_sessions(&mut self, scope: ConversationScope) -> 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; + } + } + } + // The conversation's history is gone — release ownership so the next + // event may be served by any agent with a fresh session. + self.scope_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. + /// Channel-level model switch (desktop `switch_model` control frame). + /// + /// Desktop model selection is **channel-level desired state**: a + /// channel's conversations (channel-level + threads) may be spread across + /// several agents, idle and active, so a single-agent switch would leave + /// stale sessions behind. This method: + /// + /// 1. Validates `model_id` against the first cached catalog (all slots + /// run the same agent binary, so one catalog answers for all). No + /// catalog cached anywhere → defer validation to apply time, as + /// before. + /// 2. Records the desired model for the channel, so any agent that later + /// claims a conversation under it inherits the model at claim time + /// (see [`try_claim_for_scope`](Self::try_claim_for_scope)). + /// 3. Invalidates sessions under the channel on **every idle holder** and + /// stamps its slot with this switch generation. Checked-out holders are + /// reconciled when they return, including sessions for sibling scopes + /// that were not themselves in flight. /// - /// Pre-cancel guard: the desired model is validated against the agent's - /// cached catalog *before* the session is invalidated, so an unsupported - /// pick is rejected without disturbing the existing session. + /// Active (checked-out) conversations are NOT handled here — the caller + /// signals each in-flight task under the channel with + /// `ControlSignal::SwitchModel`, whose in-task handler sets the model and + /// requeues the turn. /// - /// Returns [`IdleSwitchResult`] describing what happened. The model does not - /// take effect — and the panel does not reflect it — until the agent next - /// runs a turn (no live session exists to re-emit `session_config_captured` - /// from an idle agent). This lag is intentional: faking the emit would - /// surface an override the session has not actually applied. - pub fn switch_idle_agent_model( + /// Returns [`IdleSwitchResult`] describing what happened. The model does + /// not take effect — and the panel does not reflect it — until an agent + /// next runs a turn in the channel (no live session exists to re-emit + /// `session_config_captured` from an idle agent). This lag is + /// intentional: faking the emit would surface an override the session + /// has not actually applied. + pub fn set_channel_desired_model( &mut self, channel_id: Uuid, model_id: &str, ) -> IdleSwitchResult { - let Some(agent) = self + // Pre-cancel guard against the first cached catalog. None cached = + // no session ever created anywhere; defer validation to apply time. + if let Some(caps) = self .agents - .iter_mut() + .iter() .flatten() - .find(|a| a.state.sessions.contains_key(&channel_id)) - else { - return IdleSwitchResult::NoIdleAgent; - }; - - // Pre-cancel guard against the cached catalog. None = catalog not yet - // populated (no session ever created); defer validation to apply time. - if let Some(caps) = agent.model_capabilities.as_ref() { + .find_map(|a| a.model_capabilities.as_ref()) + { if !model_in_catalog( &caps.config_options_raw, caps.available_models_raw.as_ref(), @@ -699,14 +926,54 @@ impl AgentPool { } } - agent.desired_model = Some(model_id.to_string()); - agent.model_overridden = true; - agent.state.invalidate_channel(&channel_id); - IdleSwitchResult::Switched + let mut generation = self.next_channel_model_generation.wrapping_add(1); + if generation == 0 { + // Preserve the mismatch invariant even after the theoretical u64 + // wrap: clearing slot stamps forces every live slot to reconcile. + self.slot_channel_model_generations.clear(); + generation = 1; + } + self.next_channel_model_generation = generation; + self.channel_desired_models.insert( + channel_id, + ChannelDesiredModel { + model_id: model_id.to_string(), + generation, + }, + ); + + let mut applied = 0; + let mut reconciled_idle_slots = HashSet::new(); + for agent in self.agents.iter_mut().flatten() { + reconciled_idle_slots.insert(agent.index); + if agent.state.invalidate_channel(&channel_id) { + applied += 1; + } + self.slot_channel_model_generations + .insert((agent.index, channel_id), generation); + } + // Invalidation destroys the history that required sticky ownership. + // Release ownership only for idle slots reconciled above; checked-out + // holders retain ownership until their sessions are invalidated on + // return (or by their active SwitchModel signal). + self.scope_owners.retain(|scope, owner| { + scope.channel_id != channel_id || !reconciled_idle_slots.contains(owner) + }); + if applied > 0 { + IdleSwitchResult::Switched + } else { + IdleSwitchResult::NoIdleAgent + } + } + + /// Test-only visibility into conversation ownership. + #[cfg(test)] + pub fn scope_owner(&self, scope: &ConversationScope) -> Option { + self.scope_owners.get(scope).copied() } } -/// Outcome of [`AgentPool::switch_idle_agent_model`]. +/// Outcome of [`AgentPool::set_channel_desired_model`]. #[derive(Debug, PartialEq, Eq)] pub enum IdleSwitchResult { /// `desired_model` set and the channel session invalidated. @@ -1212,13 +1479,14 @@ pub async fn run_prompt_task( control_rx: Option>, turn_id: String, ) { - // Is this a channel prompt or a heartbeat? + // Is this a conversation prompt or a heartbeat? let source = match &batch { - Some(b) => PromptSource::Channel(b.channel_id), + Some(b) => PromptSource::Conversation(b.scope), None => PromptSource::Heartbeat, }; + // Observer frames stay channel-keyed: the desktop groups turns by channel. let observer_channel_id = match &source { - PromptSource::Channel(channel_id) => Some(*channel_id), + PromptSource::Conversation(scope) => Some(scope.channel_id), PromptSource::Heartbeat => None, }; let turn_started_at = chrono::Utc::now().to_rfc3339(); @@ -1236,7 +1504,7 @@ pub async fn run_prompt_task( "turn_started", serde_json::json!({ "source": match &source { - PromptSource::Channel(_) => "channel", + PromptSource::Conversation(_) => "channel", PromptSource::Heartbeat => "heartbeat", }, "triggeringEventIds": triggering_event_ids, @@ -1299,8 +1567,9 @@ pub async fn run_prompt_task( // we do it here and cache the rendered section in `state.core_sections`. // // Core is keyed by (agent_keys, owner) — both fixed for the process — so - // it is identical across channels; the per-channel cache just avoids a - // re-fetch on each new session and is cleared on session invalidation. + // it is identical across conversations; the per-conversation cache just + // avoids a re-fetch on each new session and is cleared on session + // invalidation. // // Failure modes (all fail open — no crash, no block): // * no owner configured → skip (no NIP-AE namespace exists) @@ -1317,11 +1586,11 @@ pub async fn run_prompt_task( // // Operator opt-out: `--no-memory` / `BUZZ_ACP_NO_MEMORY` skips the fetch. if ctx.memory_enabled { - if let (PromptSource::Channel(cid), Some(owner_pk)) = + if let (PromptSource::Conversation(scope), Some(owner_pk)) = (&source, ctx.agent_owner_pubkey.as_ref()) { - 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_session = !agent.state.sessions.contains_key(scope); + if is_new_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); @@ -1335,7 +1604,7 @@ pub async fn run_prompt_task( Err(_) => { tracing::warn!( target: "engram::core", - channel = %cid, + scope = %scope, timeout_ms = CORE_FETCH_TIMEOUT.as_millis() as u64, "core fetch timed out — emitting no section" ); @@ -1345,11 +1614,11 @@ pub async fn run_prompt_task( if let Some(rendered) = section { tracing::info!( target: "engram::core", - channel = %cid, + scope = %scope, 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, rendered); } } } @@ -1367,49 +1636,54 @@ 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; - if let PromptSource::Channel(cid) = &source { - let is_new_channel_session = !agent.state.sessions.contains_key(cid); - if is_new_channel_session && !agent.state.canvas_sections.contains_key(cid) { - // Resolve DM status: prefer the startup cache, lazy-fetch as fallback. + let mut pending_canvas: Option<(ConversationScope, String)> = None; + if let PromptSource::Conversation(scope) = &source { + let is_new_session = !agent.state.sessions.contains_key(scope); + if is_new_session && !agent.state.canvas_sections.contains_key(scope) { + // Resolve DM status: prefer the shared cache, lazy-fetch as + // fallback (caching the result for later scope derivation). // Unknown → treat as DM (fail-closed). - let is_dm = match ctx.channel_info.get(cid) { + let cid = scope.channel_id; + let is_dm = match cached_channel_info(&ctx, cid) { Some(ci) => ci.channel_type == "dm", - None => fetch_channel_info(*cid, &ctx.rest_client) - .await - .map(|ci| ci.channel_type == "dm") - .unwrap_or(true), + None => match fetch_channel_info(cid, &ctx.rest_client).await { + Some(ci) => { + cache_channel_info(&ctx, cid, &ci); + ci.channel_type == "dm" + } + None => true, + }, }; if !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, section)); } } } } // The core section to fold into the system prompt for this turn's session. - // Channel-scoped; heartbeats carry no owner core. + // Conversation-scoped; heartbeats carry no owner core. let agent_core: Option = match &source { - PromptSource::Channel(cid) => agent.state.core_sections.get(cid).cloned(), + PromptSource::Conversation(scope) => agent.state.core_sections.get(scope).cloned(), PromptSource::Heartbeat => None, }; - // The canvas metadata section — channel-scoped, absent for heartbeats/DMs. + // The canvas metadata section — conversation-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::Conversation(scope) => agent .state .canvas_sections - .get(cid) + .get(scope) .cloned() .or_else(|| pending_canvas.as_ref().map(|(_, s)| s.clone())), PromptSource::Heartbeat => None, }; let (session_id, is_new_session) = match &source { - PromptSource::Channel(cid) => { - if let Some(sid) = agent.state.sessions.get(cid) { + PromptSource::Conversation(scope) => { + if let Some(sid) = agent.state.sessions.get(scope) { (sid.clone(), false) } else { // Create new session with model application. @@ -1424,12 +1698,12 @@ pub async fn run_prompt_task( Ok(sid) => { tracing::info!( target: "pool::session", - "created session {sid} for channel {cid}" + "created session {sid} for conversation {scope}" ); - agent.state.sessions.insert(*cid, sid.clone()); + agent.state.sessions.insert(*scope, sid.clone()); // Commit canvas only after session creation succeeds (I3). - if let Some((pending_cid, section)) = pending_canvas.take() { - agent.state.canvas_sections.insert(pending_cid, section); + if let Some((pending_scope, section)) = pending_canvas.take() { + agent.state.canvas_sections.insert(pending_scope, section); } (sid, true) } @@ -1520,11 +1794,12 @@ pub async fn run_prompt_task( ); if is_new_session { - if let (PromptSource::Channel(cid), Some(ref initial_msg)) = (&source, &ctx.initial_message) + if let (PromptSource::Conversation(scope), Some(ref initial_msg)) = + (&source, &ctx.initial_message) { tracing::info!( target: "pool::session", - "sending initial_message to session {session_id} for channel {cid}" + "sending initial_message to session {session_id} for conversation {scope}" ); // For agents with systemPrompt support (protocol_version >= 2), // base_prompt is delivered via the system role in session/new. @@ -1564,7 +1839,7 @@ pub async fn run_prompt_task( Ok(stop_reason) => { tracing::info!( target: "pool::session", - "initial_message complete for channel {cid}: {stop_reason:?}" + "initial_message complete for conversation {scope}: {stop_reason:?}" ); } Err(AcpError::AgentExited) => { @@ -1582,7 +1857,7 @@ pub async fn run_prompt_task( Err(AcpError::IdleTimeout(_)) => { tracing::warn!( target: "pool::session", - "initial_message idle timeout ({}s) for channel {cid} — cancelling", + "initial_message idle timeout ({}s) for conversation {scope} — cancelling", ctx.idle_timeout.as_secs() ); match agent @@ -1627,7 +1902,7 @@ pub async fn run_prompt_task( let recently_active = silence < RECENT_ACTIVITY_WINDOW; tracing::error!( target: "pool::session", - "hard timeout ({}s cap, silence {silence:?}, recently_active={recently_active}) during initial_message for channel {cid} — agent process is unrecoverable", + "hard timeout ({}s cap, silence {silence:?}, recently_active={recently_active}) during initial_message for conversation {scope} — agent process is unrecoverable", ctx.max_turn_duration.as_secs() ); agent.state.invalidate_all(); @@ -1644,7 +1919,7 @@ pub async fn run_prompt_task( Err(e) => { tracing::error!( target: "pool::session", - "initial_message failed for channel {cid}: {e} — invalidating session" + "initial_message failed for conversation {scope}: {e} — invalidating session" ); agent.state.invalidate(&source); send_prompt_result( @@ -1682,13 +1957,20 @@ pub async fn run_prompt_task( vec![text] } else if let Some(ref b) = batch { // Build prompt from batch with context enrichment. - // Try startup cache first; lazy-fetch via REST for dynamic channels. - let channel_info = match ctx.channel_info.get(&b.channel_id) { + // Try the shared cache first; lazy-fetch via REST for dynamic + // channels, writing back on success so scope derivation self-heals. + let channel_info = match cached_channel_info(&ctx, b.channel_id()) { Some(ci) => Some(PromptChannelInfo { name: ci.name.clone(), channel_type: ci.channel_type.clone(), }), - None => fetch_channel_info(b.channel_id, &ctx.rest_client).await, + None => { + let fetched = fetch_channel_info(b.channel_id(), &ctx.rest_client).await; + if let Some(ref ci) = fetched { + cache_channel_info(&ctx, b.channel_id(), ci); + } + fetched + } }; let conversation_context = if ctx.context_message_limit > 0 { @@ -1710,7 +1992,7 @@ pub async fn run_prompt_task( if let Some(ref cmd) = slash_command { tracing::info!( target: "pool::prompt", - channel = %b.channel_id, + channel = %b.scope, command = %cmd, "slash-command pass-through" ); @@ -1948,8 +2230,8 @@ 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::Conversation(scope) => { + let count = agent.state.turn_counts.entry(*scope).or_insert(0); *count += 1; *count >= limit } @@ -2176,6 +2458,61 @@ where f().await } +/// Read a clone of the cached metadata for `channel_id`, if present. +pub(crate) fn cached_channel_info(ctx: &PromptContext, channel_id: Uuid) -> Option { + // Poison recovery: the map is plain data, safe to read after a panic. + let map = ctx + .channel_info + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + map.get(&channel_id).cloned() +} + +/// Write fetched channel metadata back into the shared cache (first write +/// wins) so later events — conversation-scope derivation, DM checks — see it +/// without another relay round-trip. +pub(crate) fn cache_channel_info(ctx: &PromptContext, channel_id: Uuid, info: &PromptChannelInfo) { + let mut map = ctx + .channel_info + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + map.entry(channel_id).or_insert_with(|| ChannelInfo { + name: info.name.clone(), + channel_type: info.channel_type.clone(), + }); +} + +/// Resolve and cache metadata for a dynamically joined channel. +/// +/// Called by the membership-add resolver before opening event delivery, so a +/// channel joined after startup gains correct DM/thread scoping on its first +/// event. Returns `true` once metadata is cached; callers retry failures while +/// the membership remains pending. +pub(crate) async fn refresh_channel_info(ctx: &PromptContext, channel_id: Uuid) -> bool { + if cached_channel_info(ctx, channel_id).is_some() { + return true; + } + match fetch_channel_info(channel_id, &ctx.rest_client).await { + Some(info) => { + cache_channel_info(ctx, channel_id, &info); + tracing::info!( + channel_id = %channel_id, + channel_type = %info.channel_type, + "cached metadata for dynamically joined channel" + ); + true + } + None => { + tracing::warn!( + channel_id = %channel_id, + "could not resolve metadata for dynamically joined channel — \ + subscription remains closed until retry succeeds" + ); + false + } + } +} + /// Lazy-fetch channel metadata for a channel not in the startup discovery cache. /// /// Handles channels added dynamically via membership notifications after startup. @@ -2467,12 +2804,12 @@ async fn fetch_conversation_context( 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.rest_client).await; + return fetch_thread_context(batch.channel_id(), &root_id, limit, &ctx.rest_client).await; } // DM non-reply: fetch recent conversation history. if is_dm { - return fetch_dm_context(batch.channel_id, limit, &ctx.rest_client).await; + return fetch_dm_context(batch.channel_id(), limit, &ctx.rest_client).await; } None @@ -3013,7 +3350,7 @@ fn classify_control_cancel_failure( /// Log a stop reason at the appropriate tracing level. fn log_stop_reason(source: &PromptSource, stop_reason: &StopReason) { let label = match source { - PromptSource::Channel(cid) => format!("channel {cid}"), + PromptSource::Conversation(scope) => format!("conversation {scope}"), PromptSource::Heartbeat => "heartbeat".to_string(), }; match stop_reason { @@ -4074,7 +4411,7 @@ mod tests { .unwrap(); let author_hex = event.pubkey.to_hex(); let batch = FlushBatch { - channel_id: Uuid::new_v4(), + scope: ConversationScope::channel(Uuid::new_v4()), events: vec![crate::queue::BatchEvent { event, prompt_tag: "@mention".into(), @@ -4200,9 +4537,9 @@ mod tests { assert_eq!(pct_encode(" "), "%20"); } - fn make_state() -> (SessionState, Uuid, Uuid) { - let ch_a = Uuid::new_v4(); - let ch_b = Uuid::new_v4(); + fn make_state() -> (SessionState, ConversationScope, ConversationScope) { + let ch_a = ConversationScope::channel(Uuid::new_v4()); + let ch_b = ConversationScope::channel(Uuid::new_v4()); let mut s = SessionState::default(); s.sessions.insert(ch_a, "sess-a".into()); s.sessions.insert(ch_b, "sess-b".into()); @@ -4221,14 +4558,14 @@ mod tests { apply_completed_before_control_signal( &mut s, - &PromptSource::Channel(ch_a), + &PromptSource::Conversation(ch_a), &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.has_channel_state(&ch_a)); + assert!(!s.has_scope_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"); @@ -4242,7 +4579,7 @@ mod tests { apply_completed_before_control_signal( &mut s, - &PromptSource::Channel(ch_a), + &PromptSource::Conversation(ch_a), &ControlSignal::Cancel, ); @@ -4255,12 +4592,12 @@ mod tests { #[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::Conversation(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.has_channel_state(&ch_a)); + assert!(!s.has_scope_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); @@ -4300,8 +4637,8 @@ mod tests { #[test] 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)); + let ghost = ConversationScope::channel(Uuid::new_v4()); + s.invalidate(&PromptSource::Conversation(ghost)); // Everything still intact. assert_eq!(s.sessions.len(), 2); @@ -4322,13 +4659,13 @@ mod tests { } #[test] - fn test_invalidate_channel_returns_true_when_session_existed() { + fn test_invalidate_scope_returns_true_when_session_existed() { let (mut s, ch_a, ch_b) = make_state(); - assert!(s.invalidate_channel(&ch_a)); + assert!(s.invalidate_scope(&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.has_channel_state(&ch_a)); + assert!(!s.has_scope_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); @@ -4339,10 +4676,10 @@ mod tests { } #[test] - fn test_invalidate_channel_returns_false_when_no_session() { + fn test_invalidate_scope_returns_false_when_no_session() { let (mut s, _ch_a, _ch_b) = make_state(); - let ghost = Uuid::new_v4(); - assert!(!s.invalidate_channel(&ghost)); + let ghost = ConversationScope::channel(Uuid::new_v4()); + assert!(!s.invalidate_scope(&ghost)); // Nothing changed. assert_eq!(s.sessions.len(), 2); assert_eq!(s.turn_counts.len(), 2); @@ -4353,14 +4690,14 @@ mod tests { // Simulates handle_prompt_result: channels removed while agent // was checked out should have both sessions and turn_counts stripped. let (mut s, ch_a, ch_b) = make_state(); - let removed = vec![ch_a]; + let removed = vec![ch_a.channel_id]; 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.has_channel_state(&ch_a)); + assert!(!s.has_scope_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"); @@ -4376,11 +4713,11 @@ mod tests { // re-creates a fresh session that re-applies the new desired_model. apply_completed_before_control_signal( &mut s, - &PromptSource::Channel(ch_a), + &PromptSource::Conversation(ch_a), &ControlSignal::SwitchModel("gpt-5".into()), ); - assert!(!s.has_channel_state(&ch_a)); + assert!(!s.has_scope_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); @@ -4394,13 +4731,13 @@ mod tests { // `unwrap_or(CancelReason::Steer)` at the requeue site and preserve a // batch that should have been discarded. - fn one_event_batch(channel_id: Uuid) -> FlushBatch { + fn one_event_batch(scope: ConversationScope) -> FlushBatch { let keys = Keys::generate(); let event = EventBuilder::new(Kind::Custom(9), "test") .sign_with_keys(&keys) .unwrap(); FlushBatch { - channel_id, + scope, events: vec![crate::queue::BatchEvent { event, prompt_tag: "test".into(), @@ -4427,8 +4764,7 @@ mod tests { ctx.dedup_mode = DedupMode::Queue; for (signal, expected_reason) in cases { - let channel_id = Uuid::new_v4(); - let batch = one_event_batch(channel_id); + let batch = one_event_batch(ConversationScope::channel(Uuid::new_v4())); let result = requeue_cancelled_batch(&ctx, signal.clone(), Some(batch)); match expected_reason { Some(reason) => { @@ -4580,8 +4916,7 @@ mod tests { ]; for case in cases { - let channel_id = Uuid::new_v4(); - let batch = one_event_batch(channel_id); + let batch = one_event_batch(ConversationScope::channel(Uuid::new_v4())); let failure = classify_control_cancel_failure( &ctx, (case.error)(), @@ -4949,6 +5284,7 @@ mod tests { acp, state: SessionState::default(), model_capabilities: None, + baseline_model: None, desired_model: None, model_overridden: false, agent_name: "unknown".into(), @@ -5007,6 +5343,7 @@ mod tests { acp, state: SessionState::default(), model_capabilities: None, + baseline_model: None, desired_model: None, model_overridden: false, agent_name: "unknown".into(), @@ -5246,7 +5583,7 @@ mod tests { keys: agent_keys.clone(), auth_tag_json: None, }, - channel_info: std::collections::HashMap::new(), + channel_info: std::sync::RwLock::new(std::collections::HashMap::new()), context_message_limit: 0, max_turns_per_session: 0, permission_mode: PermissionMode::Default, @@ -5303,14 +5640,14 @@ mod tests { // ── canvas_sections cache invalidation ─────────────────────────────────── #[test] - fn test_invalidate_channel_clears_canvas_section() { - let ch = Uuid::new_v4(); + fn test_invalidate_scope_clears_canvas_section() { + let ch = ConversationScope::channel(Uuid::new_v4()); let mut s = SessionState::default(); s.sessions.insert(ch, "sess".into()); s.canvas_sections .insert(ch, "[Channel Canvas]\nrev abc".into()); - s.invalidate_channel(&ch); + s.invalidate_scope(&ch); assert!(!s.canvas_sections.contains_key(&ch)); assert!(!s.sessions.contains_key(&ch)); @@ -5318,8 +5655,8 @@ mod tests { #[test] fn test_invalidate_all_clears_canvas_sections() { - let ch_a = Uuid::new_v4(); - let ch_b = Uuid::new_v4(); + let ch_a = ConversationScope::channel(Uuid::new_v4()); + let ch_b = ConversationScope::channel(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()); @@ -5333,26 +5670,26 @@ mod tests { #[test] fn test_invalidate_channel_leaves_other_channels_canvas_intact() { - let ch_a = Uuid::new_v4(); - let ch_b = Uuid::new_v4(); + let ch_a = ConversationScope::channel(Uuid::new_v4()); + let ch_b = ConversationScope::channel(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.invalidate_channel(&ch_a); + s.invalidate_channel(&ch_a.channel_id); assert!(!s.canvas_sections.contains_key(&ch_a)); assert_eq!(s.canvas_sections.get(&ch_b).unwrap(), "canvas-b"); } #[test] - fn test_has_channel_state_true_when_only_canvas_section_present() { - let ch = Uuid::new_v4(); + fn test_has_scope_state_true_when_only_canvas_section_present() { + let ch = ConversationScope::channel(Uuid::new_v4()); let mut s = SessionState::default(); s.canvas_sections.insert(ch, "canvas".into()); - assert!(s.has_channel_state(&ch)); + assert!(s.has_scope_state(&ch)); } // ── canvas_section_from_query_response ─────────────────────────────────── @@ -5565,3 +5902,116 @@ mod tests { ); } } + +#[cfg(test)] +mod thread_scope_session_tests { + use super::*; + use crate::scope::ConversationScope; + use nostr::EventId; + use uuid::Uuid; + + fn thread_scope(channel_id: Uuid, root_byte: char) -> ConversationScope { + let root = EventId::from_hex(&root_byte.to_string().repeat(64)).expect("valid hex"); + ConversationScope::thread(channel_id, root) + } + + /// Two threads in one channel — and the channel-level conversation — hold + /// distinct session entries; replies in one thread reuse that thread's + /// session key. + #[test] + fn threads_and_channel_hold_distinct_sessions() { + let ch = Uuid::new_v4(); + let thread_a = thread_scope(ch, 'a'); + let thread_b = thread_scope(ch, 'b'); + let channel = ConversationScope::channel(ch); + + let mut s = SessionState::default(); + s.sessions.insert(thread_a, "sess-thread-a".into()); + s.sessions.insert(thread_b, "sess-thread-b".into()); + s.sessions.insert(channel, "sess-channel".into()); + + // A reply in thread A resolves to thread A's session (reuse), not the + // channel session and not thread B's. + assert_eq!(s.sessions.get(&thread_a).unwrap(), "sess-thread-a"); + assert_eq!(s.sessions.get(&thread_b).unwrap(), "sess-thread-b"); + assert_eq!(s.sessions.get(&channel).unwrap(), "sess-channel"); + assert_eq!(s.sessions.len(), 3); + } + + /// Rotating one thread's scope leaves the sibling thread and the + /// channel-level conversation untouched. + #[test] + fn invalidate_scope_targets_only_that_thread() { + let ch = Uuid::new_v4(); + let thread_a = thread_scope(ch, 'a'); + let thread_b = thread_scope(ch, 'b'); + let channel = ConversationScope::channel(ch); + + let mut s = SessionState::default(); + for (scope, sid) in [(thread_a, "a"), (thread_b, "b"), (channel, "c")] { + s.sessions.insert(scope, sid.into()); + s.turn_counts.insert(scope, 2); + s.core_sections.insert(scope, "core".into()); + s.canvas_sections.insert(scope, "canvas".into()); + } + + assert!(s.invalidate_scope(&thread_a)); + + assert!(!s.sessions.contains_key(&thread_a)); + assert!(!s.turn_counts.contains_key(&thread_a)); + assert!(!s.core_sections.contains_key(&thread_a)); + assert!(!s.canvas_sections.contains_key(&thread_a)); + assert_eq!(s.sessions.get(&thread_b).unwrap(), "b"); + assert_eq!(s.sessions.get(&channel).unwrap(), "c"); + assert_eq!(*s.turn_counts.get(&thread_b).unwrap(), 2); + } + + /// Channel removal invalidates every conversation scope under that + /// channel — channel-level and all threads — and nothing else. + #[test] + fn invalidate_channel_sweeps_channel_and_thread_scopes() { + let ch = Uuid::new_v4(); + let other = Uuid::new_v4(); + let thread_a = thread_scope(ch, 'a'); + let thread_b = thread_scope(ch, 'b'); + let channel = ConversationScope::channel(ch); + let other_scope = ConversationScope::channel(other); + let other_thread = thread_scope(other, 'e'); + + let mut s = SessionState::default(); + for scope in [thread_a, thread_b, channel, other_scope, other_thread] { + s.sessions.insert(scope, "sess".into()); + s.turn_counts.insert(scope, 1); + s.core_sections.insert(scope, "core".into()); + s.canvas_sections.insert(scope, "canvas".into()); + } + + assert!(s.invalidate_channel(&ch)); + + for scope in [thread_a, thread_b, channel] { + assert!(!s.has_scope_state(&scope), "{scope} must be swept"); + } + for scope in [other_scope, other_thread] { + assert!(s.has_scope_state(&scope), "{scope} must survive"); + } + // Idempotent: nothing left to remove. + assert!(!s.invalidate_channel(&ch)); + } + + /// Proactive rotation counters are per-conversation: turns in one thread + /// must not advance the rotation clock of siblings. + #[test] + fn turn_counts_are_per_conversation() { + let ch = Uuid::new_v4(); + let thread_a = thread_scope(ch, 'a'); + let channel = ConversationScope::channel(ch); + + let mut s = SessionState::default(); + *s.turn_counts.entry(thread_a).or_insert(0) += 1; + *s.turn_counts.entry(thread_a).or_insert(0) += 1; + *s.turn_counts.entry(channel).or_insert(0) += 1; + + assert_eq!(*s.turn_counts.get(&thread_a).unwrap(), 2); + assert_eq!(*s.turn_counts.get(&channel).unwrap(), 1); + } +} diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 029bf86db..72aca3dc6 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -1,16 +1,19 @@ //! Event queue state machine for buzz-acp. //! -//! Manages per-channel event queues with per-channel in-flight tracking. -//! When the harness is ready to prompt the agent, it flushes the channel with -//! the oldest pending event, draining ALL events for that channel into a single -//! batch. Multiple channels can be in-flight simultaneously; each channel is -//! independent. +//! Manages per-conversation event queues with per-conversation in-flight +//! tracking, keyed by [`ConversationScope`] — channel-level for unthreaded +//! messages and DMs, thread-level for thread replies in regular channels. +//! When the harness is ready to prompt the agent, it flushes the conversation +//! with the oldest pending event, draining ALL events for that conversation +//! into a single batch — events from different scopes are never combined. +//! Multiple conversations (including multiple threads in one channel) can be +//! in-flight simultaneously; each conversation is independent. //! //! ## Dedup modes //! -//! - **Drop** (default) — while a prompt is in-flight for channel C, new events -//! for channel C are silently dropped (debug-logged). Events for other channels -//! still queue normally. +//! - **Drop** (default) — while a prompt is in-flight for conversation C, new +//! events for C are silently dropped (debug-logged). Events for other +//! conversations still queue normally. //! - **Queue** — all events accumulate; batched on the next flush cycle. use nostr::{Event, ToBech32}; @@ -19,8 +22,9 @@ use std::time::{Duration, Instant}; use uuid::Uuid; use crate::config::DedupMode; +use crate::scope::ConversationScope; -/// Maximum events queued per channel before oldest events are dropped. +/// Maximum events queued per conversation before oldest events are dropped. const MAX_PENDING_PER_CHANNEL: usize = 500; /// Maximum events drained into a single batch. @@ -44,7 +48,8 @@ const DEFAULT_IN_FLIGHT_DEADLINE_SECS: u64 = 7300; /// An event waiting in the queue. #[derive(Debug, Clone)] pub struct QueuedEvent { - pub channel_id: Uuid, + /// Conversation this event belongs to (queue lane + session key). + pub scope: ConversationScope, pub event: Event, pub received_at: Instant, /// Tag identifying which rule (or mode) matched this event. @@ -73,9 +78,13 @@ pub enum CancelReason { } /// A batch of events to prompt the agent with. +/// +/// All events in a batch share one [`ConversationScope`] — the flush path +/// drains a single scope's queue, so distinct threads (or a thread and the +/// surrounding channel conversation) are never merged into one prompt. #[derive(Debug, Clone)] pub struct FlushBatch { - pub channel_id: Uuid, + pub scope: ConversationScope, pub events: Vec, /// Events from a cancelled batch that triggered this re-prompt. /// Empty for normal (non-cancel) batches. When non-empty, `format_prompt()` @@ -89,70 +98,85 @@ pub struct FlushBatch { pub cancel_reason: Option, } -/// Per-channel event queue with per-channel in-flight enforcement. +impl FlushBatch { + /// The channel this batch's conversation lives in. + pub fn channel_id(&self) -> Uuid { + self.scope.channel_id + } +} + +/// Per-conversation event queue with per-conversation in-flight enforcement. +/// +/// Every map below is keyed by [`ConversationScope`], so two threads in one +/// channel occupy independent queue lanes, in-flight slots, retry throttles, +/// and cancel/steer side tables — they cannot batch, steer, cancel, or queue +/// into each other. Channel-wide teardown (membership removal) goes through +/// [`drain_channel`](Self::drain_channel), which sweeps every scope under the +/// channel. /// /// # State Machine /// /// ```text /// State: -/// queues: Map> (capped at MAX_PENDING_PER_CHANNEL) -/// in_flight_channels: HashSet -/// in_flight_deadlines: Map (auto-expire after in_flight_deadline) -/// retry_after: Map -/// retry_counts: Map (dead-letter after MAX_RETRIES) +/// queues: Map> (capped at MAX_PENDING_PER_CHANNEL) +/// in_flight_scopes: HashSet +/// in_flight_deadlines: Map (auto-expire after in_flight_deadline) +/// retry_after: Map +/// retry_counts: Map (dead-letter after MAX_RETRIES) /// dedup_mode: DedupMode /// /// Transitions: /// push(event): -/// if dedup_mode == Drop AND in_flight_channels.contains(event.channel_id): +/// if dedup_mode == Drop AND in_flight_scopes.contains(event.scope): /// debug log + discard -/// else if queues[channel].len() >= MAX_PENDING_PER_CHANNEL: +/// else if queues[scope].len() >= MAX_PENDING_PER_CHANNEL: /// drop oldest (pop_front), warn, push_back new event /// else: -/// queues[event.channel_id].push_back(event) +/// queues[event.scope].push_back(event) /// /// flush_next() → Option: /// expire any stuck in-flight entries past their deadline -/// candidates = channels where queue non-empty -/// AND NOT in in_flight_channels -/// AND (no retry_after OR retry_after[c] <= now) +/// candidates = scopes where queue non-empty +/// AND NOT in in_flight_scopes +/// AND (no retry_after OR retry_after[s] <= now) /// if candidates empty: return None -/// channel = pick candidate with oldest head event (min received_at) -/// events = drain up to MAX_BATCH_EVENTS from queues[channel] -/// in_flight_channels.insert(channel) -/// in_flight_deadlines.insert(channel, now + in_flight_deadline) -/// return Some(FlushBatch { channel, events }) +/// scope = pick candidate with oldest head event (min received_at) +/// events = drain up to MAX_BATCH_EVENTS from queues[scope] +/// in_flight_scopes.insert(scope) +/// in_flight_deadlines.insert(scope, now + in_flight_deadline) +/// return Some(FlushBatch { scope, events }) /// -/// mark_complete(channel_id): -/// in_flight_channels.remove(channel_id) -/// in_flight_deadlines.remove(channel_id) -/// retry_counts.remove(channel_id) +/// mark_complete(scope): +/// in_flight_scopes.remove(scope) +/// in_flight_deadlines.remove(scope) +/// retry_counts.remove(scope) /// clean up expired retry_after entry if present /// /// requeue(batch): -/// increment retry_counts[channel] -/// if retry_counts[channel] > MAX_RETRIES: dead-letter (log ERROR, return batch to caller) +/// increment retry_counts[scope] +/// if retry_counts[scope] > MAX_RETRIES: dead-letter (log ERROR, return batch to caller) /// else: push_front with original received_at, set exponential backoff retry_after with jitter /// ``` pub struct EventQueue { - queues: HashMap>, - in_flight_channels: HashSet, - /// Per-channel deadline for auto-expiring stuck in-flight entries. - in_flight_deadlines: HashMap, + queues: HashMap>, + in_flight_scopes: HashSet, + /// Per-conversation 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-conversation retry attempt counter for exponential backoff / dead-lettering. + retry_counts: HashMap, dedup_mode: DedupMode, - /// Events from cancelled batches, keyed by channel. Merged into the next - /// `FlushBatch` for that channel as `cancelled_events` so `format_prompt()` - /// can produce annotated "[Previous request — interrupted]" sections. - cancelled_batches: HashMap>, - /// Why each channel's cancelled batch was cancelled (steer vs interrupt). - /// Set by `requeue_as_cancelled`, consumed by `flush_next` to set - /// `FlushBatch::cancel_reason`. Keyed by channel, cleared on flush. - cancel_reasons: HashMap, + /// Events from cancelled batches, keyed by conversation. Merged into the + /// next `FlushBatch` for that conversation as `cancelled_events` so + /// `format_prompt()` can produce annotated + /// "[Previous request — interrupted]" sections. + cancelled_batches: HashMap>, + /// Why each conversation's cancelled batch was cancelled (steer vs + /// interrupt). Set by `requeue_as_cancelled`, consumed by `flush_next` to + /// set `FlushBatch::cancel_reason`. Keyed by conversation, 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,10 +187,11 @@ 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>, - /// 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. + withheld_native_steer: HashMap>, + /// Duration after which an in-flight conversation is auto-expired as + /// orphaned. Must be strictly greater than `max_turn_duration` so a turn + /// running to the hard cap returns via `mark_complete` before the + /// backstop fires. in_flight_deadline: Duration, } @@ -179,7 +204,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(), @@ -200,20 +225,20 @@ impl EventQueue { self } - /// Monotonically extend an existing in-flight deadline for `channel_id`. + /// Monotonically extend an existing in-flight deadline for `scope`. /// /// Called when a successful steer grants a fresh turn budget. The new /// deadline is `max(current, now + max_turn_secs + buffer)` — it never - /// moves backward. If the channel is not in-flight (already completed + /// moves backward. If the conversation 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: ConversationScope, max_turn_secs: u64) { + 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, + %scope, "extending in-flight deadline by {max_turn_secs}s + {IN_FLIGHT_DEADLINE_BUFFER_SECS}s buffer" ); *current = extended; @@ -221,28 +246,28 @@ impl EventQueue { } } - /// Push an event into the queue for its channel. + /// Push an event into the queue for its conversation. /// - /// In [`DedupMode::Drop`], events for any currently in-flight channel are - /// silently discarded (debug-logged). + /// In [`DedupMode::Drop`], events for any currently in-flight conversation + /// are silently discarded (debug-logged). /// /// Returns `true` if the event was accepted, `false` if dropped. pub fn push(&mut self, event: QueuedEvent) -> bool { if matches!(self.dedup_mode, DedupMode::Drop) - && self.in_flight_channels.contains(&event.channel_id) + && 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, + "dropping event for in-flight conversation (drop mode)" ); return false; } - let queue = self.queues.entry(event.channel_id).or_default(); - // Enforce per-channel depth cap: drop oldest to make room. + let queue = self.queues.entry(event.scope).or_default(); + // Enforce per-conversation depth cap: drop oldest to make room. if queue.len() >= MAX_PENDING_PER_CHANNEL { queue.pop_front(); tracing::warn!( - channel_id = %event.channel_id, + scope = %event.scope, limit = MAX_PENDING_PER_CHANNEL, "queue depth cap reached — dropped oldest event" ); @@ -254,14 +279,15 @@ impl EventQueue { /// Try to flush the next batch. /// /// Returns `None` if all non-in-flight, non-throttled queues are empty. - /// Otherwise picks the channel with the oldest pending event (FIFO fairness - /// across channels), drains ALL events for that channel into a single batch, - /// inserts into `in_flight_channels`, and returns the batch. + /// Otherwise picks the conversation with the oldest pending event (FIFO + /// fairness across conversations), drains ALL events for that conversation + /// into a single batch, inserts into `in_flight_scopes`, and returns the + /// batch. pub fn flush_next(&mut self) -> Option { 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) @@ -270,45 +296,45 @@ impl EventQueue { for id in expired { let lost_events = self.in_flight_batch_sizes.remove(&id).unwrap_or(0); tracing::error!( - channel_id = %id, + scope = %id, lost_events, deadline_secs = self.in_flight_deadline.as_secs(), - "BUG: in-flight channel expired without mark_complete — \ + "BUG: in-flight conversation expired without mark_complete — \ auto-releasing; {lost_events} dispatched event(s) orphaned" ); - self.in_flight_channels.remove(&id); + self.in_flight_scopes.remove(&id); self.in_flight_deadlines.remove(&id); // Recover any withheld goose-native steer events for the expired - // channel back to the queue front so normal dispatch delivers + // conversation 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(id); } - // Find the channel whose head event has the oldest received_at, - // excluding in-flight channels and throttled channels. - let channel_id = self + // Find the conversation whose head event has the oldest received_at, + // excluding in-flight conversations and throttled conversations. + let scope = self .queues .iter() .filter(|(id, q)| { !q.is_empty() - && !self.in_flight_channels.contains(id) + && !self.in_flight_scopes.contains(id) && self.retry_after.get(id).is_none_or(|&t| t <= now) }) .min_by_key(|(_, q)| q.front().unwrap().received_at) .map(|(id, _)| *id); - // Fallback: if no queued events are ready but a channel 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 { + // Fallback: if no queued events are ready but a conversation has + // cancelled events waiting (e.g., explicit !cancel with no new + // @mention), flush those as a regular batch (re-dispatch unchanged). + let scope = match scope { Some(id) => id, None => { let cancelled_id = self .cancelled_batches .keys() - .find(|id| !self.in_flight_channels.contains(id)) + .find(|id| !self.in_flight_scopes.contains(id)) .copied(); match cancelled_id { Some(id) => { @@ -316,12 +342,12 @@ impl EventQueue { // 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); + self.in_flight_scopes.insert(id); self.in_flight_deadlines .insert(id, now + self.in_flight_deadline); self.in_flight_batch_sizes.insert(id, cancelled.len()); return Some(FlushBatch { - channel_id: id, + scope: id, events: cancelled, cancelled_events: vec![], cancel_reason, @@ -333,7 +359,7 @@ impl EventQueue { }; // 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).or_default(); let drain_count = MAX_BATCH_EVENTS.min(queue.len()); let mut events: Vec = queue .drain(..drain_count) @@ -350,103 +376,102 @@ 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); self.in_flight_deadlines - .insert(channel_id, now + self.in_flight_deadline); - self.in_flight_batch_sizes.insert(channel_id, events.len()); + .insert(scope, now + self.in_flight_deadline); + self.in_flight_batch_sizes.insert(scope, 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, }) } - /// Mark the prompt for `channel_id` as complete. + /// Mark the prompt for `scope` as complete. /// - /// Removes the channel from `in_flight_channels` and `in_flight_deadlines`. + /// Removes the conversation from `in_flight_scopes` and + /// `in_flight_deadlines`. /// - /// If the channel was NOT requeued (no active `retry_after` throttle), the - /// retry counter is reset — the channel is healthy and the next failure - /// starts fresh. If the channel WAS requeued, `retry_counts` is left intact - /// so the backoff sequence continues on the next attempt. + /// If the conversation was NOT requeued (no active `retry_after` + /// throttle), the retry counter is reset — the conversation is healthy and + /// the next failure starts fresh. If the conversation WAS requeued, + /// `retry_counts` is left intact so the backoff sequence continues on the + /// next attempt. /// /// Also cleans up any already-expired `retry_after` entry. - pub fn mark_complete(&mut self, channel_id: Uuid) { - self.in_flight_channels.remove(&channel_id); - self.in_flight_deadlines.remove(&channel_id); - self.in_flight_batch_sizes.remove(&channel_id); + pub fn mark_complete(&mut self, scope: ConversationScope) { + 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 → conversation was requeued; keep retry_counts. 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); } } } /// Re-queue a batch of events that failed to process. /// - /// Events are pushed back to the **front** of the channel's queue so they - /// are processed first on the next flush cycle. This prevents event loss - /// when session creation or `session/prompt` fails transiently. + /// Events are pushed back to the **front** of the conversation's queue so + /// they are processed first on the next flush cycle. This prevents event + /// loss when session creation or `session/prompt` fails transiently. /// - /// Original `received_at` timestamps are preserved so the channel retains - /// its fairness position. The retry delay comes from exponential backoff, - /// not from resetting received_at. + /// Original `received_at` timestamps are preserved so the conversation + /// retains its fairness position. The retry delay comes from exponential + /// backoff, not from resetting received_at. /// /// After [`MAX_RETRIES`] attempts the batch is dead-lettered: logged at /// ERROR and returned to the caller (rather than requeued) so a visible /// failure notice can be posted to the channel. Returns `None` when the /// batch was requeued for another attempt. /// - /// Note: does NOT remove from `in_flight_channels` — caller must call + /// Note: does NOT remove from `in_flight_scopes` — caller must call /// `mark_complete` separately. pub fn requeue(&mut self, batch: FlushBatch) -> Option { - let channel_id = batch.channel_id; + let scope = batch.scope; let attempt = { - let count = self.retry_counts.entry(channel_id).or_insert(0); + let count = self.retry_counts.entry(scope).or_insert(0); *count += 1; *count }; if attempt > MAX_RETRIES { tracing::error!( - channel_id = %channel_id, + scope = %scope, attempt, events = batch.events.len(), "dead-lettering batch after {} retries — discarding {} events", MAX_RETRIES, batch.events.len(), ); - self.retry_counts.remove(&channel_id); - // Also clear retry_after so fresh traffic on this channel isn't - // throttled by stale backoff from the discarded poison batch. - self.retry_after.remove(&channel_id); + self.retry_counts.remove(&scope); + // Also clear retry_after so fresh traffic on this conversation + // isn't throttled by stale backoff from the discarded poison batch. + self.retry_after.remove(&scope); return Some(batch); } @@ -464,7 +489,7 @@ impl EventQueue { let delay = Duration::from_secs_f64(capped_secs as f64 * jitter); tracing::warn!( - channel_id = %channel_id, + scope = %scope, attempt, max = MAX_RETRIES, delay_secs = delay.as_secs_f64(), @@ -472,56 +497,56 @@ impl EventQueue { "requeueing failed batch with backoff" ); - let queue = self.queues.entry(channel_id).or_default(); + let queue = self.queues.entry(scope).or_default(); // Push to front in reverse order so original order is preserved. for be in batch.events.into_iter().rev() { queue.push_front(QueuedEvent { - channel_id, + scope, 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. + // Enforce per-conversation 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 { queue.pop_back(); tracing::warn!( - channel_id = %channel_id, + scope = %scope, limit = MAX_PENDING_PER_CHANNEL, "requeue overflow — dropped oldest event to enforce cap" ); } - self.retry_after.insert(channel_id, Instant::now() + delay); + self.retry_after.insert(scope, Instant::now() + delay); None } /// Re-queue a 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. + /// retry without penalizing the conversation's position in the fairness + /// queue and without imposing a retry throttle. /// - /// Does NOT set `retry_after`. Does NOT remove from `in_flight_channels` — + /// Does NOT set `retry_after`. Does NOT remove from `in_flight_scopes` — /// caller must call `mark_complete` separately. pub fn requeue_preserve_timestamps(&mut self, batch: FlushBatch) { - let channel_id = batch.channel_id; - let queue = self.queues.entry(channel_id).or_default(); + let scope = batch.scope; + let queue = self.queues.entry(scope).or_default(); // Push to front in reverse order so original order is preserved. for be in batch.events.into_iter().rev() { queue.push_front(QueuedEvent { - channel_id, + scope, event: be.event, prompt_tag: be.prompt_tag, received_at: be.received_at, }); } - // Enforce per-channel cap: trim newest (back) events if over limit. + // Enforce per-conversation cap: trim newest (back) events if over limit. while queue.len() > MAX_PENDING_PER_CHANNEL { queue.pop_back(); tracing::warn!( - channel_id = %channel_id, + scope = %scope, limit = MAX_PENDING_PER_CHANNEL, "requeue_preserve overflow — dropped newest event to enforce cap" ); @@ -529,7 +554,7 @@ impl EventQueue { } /// Requeue a cancelled batch so its events appear as `cancelled_events` - /// in the next `FlushBatch` for this channel (enabling the annotated + /// in the next `FlushBatch` for this conversation (enabling the annotated /// merged-prompt format in `format_prompt()`). /// /// `reason` records why the turn was cancelled (steer vs interrupt) so the @@ -540,15 +565,15 @@ 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 entry = self.cancelled_batches.entry(batch.scope).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(batch.scope, reason); } - /// Returns `true` if any channel has pending events that are not in-flight - /// and not throttled by `retry_after`. + /// Returns `true` if any conversation has pending events that are not + /// in-flight and not throttled by `retry_after`. /// /// Also auto-expires any stuck in-flight entries whose deadline has passed. /// This is a `&mut self` method so expiry can happen without requiring a @@ -557,7 +582,7 @@ 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) @@ -566,84 +591,92 @@ impl EventQueue { for id in expired { let lost_events = self.in_flight_batch_sizes.remove(&id).unwrap_or(0); tracing::error!( - channel_id = %id, + scope = %id, lost_events, deadline_secs = self.in_flight_deadline.as_secs(), - "BUG: in-flight channel expired without mark_complete — \ + "BUG: in-flight conversation expired without mark_complete — \ auto-releasing; {lost_events} dispatched event(s) orphaned" ); - self.in_flight_channels.remove(&id); + self.in_flight_scopes.remove(&id); self.in_flight_deadlines.remove(&id); // Symmetric with the flush_next expiry block: recover withheld - // goose-native steer events for the expired channel so they are - // not permanently orphaned in the side table. - self.recover_withheld_for_expired_channel(id); + // goose-native steer events for the expired conversation so they + // are not permanently orphaned in the side table. + self.recover_withheld_for_expired_scope(id); } self.queues.iter().any(|(id, q)| { !q.is_empty() - && !self.in_flight_channels.contains(id) + && !self.in_flight_scopes.contains(id) && self.retry_after.get(id).is_none_or(|&t| t <= now) }) || self .cancelled_batches .keys() - .any(|id| !self.in_flight_channels.contains(id)) + .any(|id| !self.in_flight_scopes.contains(id)) } - /// Number of channels with pending events. - pub fn pending_channels(&self) -> usize { + /// Number of conversations with pending events. + pub fn pending_conversations(&self) -> usize { self.queues.len() } - /// Number of queued events for a specific channel. Test-only. + /// Number of queued events for a specific conversation. 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: &ConversationScope) -> usize { + self.queues.get(scope).map_or(0, |q| q.len()) } - /// Force a channel's retry-attempt counter to `count`, simulating `count` - /// prior failed attempts without needing to drive fake flush/requeue - /// cycles through the queue (which would leave artifact events behind). - /// Test-only — lets integration tests outside this module exercise - /// `requeue()`'s dead-letter threshold directly. + /// Force a conversation's retry-attempt counter to `count`, simulating + /// `count` prior failed attempts without needing to drive fake + /// flush/requeue cycles through the queue (which would leave artifact + /// events behind). 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: ConversationScope, count: u32) { + self.retry_counts.insert(scope, count); } - /// Drop all queued (non-in-flight) events for a channel. + /// Drop all queued (non-in-flight) events for every conversation under a + /// channel — the channel-level conversation and all of its threads. /// /// Used when the agent is removed from a channel — any pending events /// for that channel are stale and should not be prompted. Does NOT /// affect in-flight prompts (those will complete normally; the agent /// may fail to act if it lost access, but that's handled by the relay). /// - /// Also clears any `retry_after` throttle for the channel. + /// Also clears any `retry_after` throttle for those conversations. /// /// Returns the event IDs of dropped events so the caller can clean up /// any reactions (👀) that were added at queue-push time. pub fn drain_channel(&mut self, channel_id: Uuid) -> Vec { - let ids = self - .queues - .remove(&channel_id) - .map(|q| q.into_iter().map(|e| e.event.id.to_hex()).collect()) - .unwrap_or_default(); - self.retry_after.remove(&channel_id); - self.retry_counts.remove(&channel_id); - self.cancelled_batches.remove(&channel_id); - self.cancel_reasons.remove(&channel_id); - self.withheld_native_steer.remove(&channel_id); - // Preserve in_flight_channels AND in_flight_deadlines: the in-flight + let mut ids = Vec::new(); + self.queues.retain(|scope, q| { + if scope.channel_id == channel_id { + ids.extend(q.iter().map(|e| e.event.id.to_hex())); + false + } else { + true + } + }); + 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 conversation). Removing deadlines + // without removing in_flight_scopes would disable auto-expiry and + // leave a wedged task permanently blocking the conversation. 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 conversation. + pub fn is_scope_in_flight(&self, scope: ConversationScope) -> bool { + self.in_flight_scopes.contains(&scope) } // ── Goose-native steer withhold (side table) ────────────────────────── @@ -652,46 +685,47 @@ impl EventQueue { // for a specific queued event, that event is moved out of `queues` into // `withheld_native_steer` so `flush_next` / `has_flushable_work` / the // contiguous drain at line 285 cannot see it — closing the race window - // between `mark_complete` (which clears `in_flight_channels`) and the + // between `mark_complete` (which clears `in_flight_scopes`) and the // ack arriving on the main loop. On `Success` the event is consumed // (`remove_event`); on `Err` / `PromptCompletedNeutral` it is released // back to the queue front (`release_native_steer`), preserving its // original `received_at` for FIFO fairness. - /// Move a queued event out of `queues[channel_id]` into the side table + /// Move a queued event out of `queues[scope]` into the side table /// to withhold it from `flush_next` while a goose-native steer is in /// flight. /// /// Returns `true` if the event was found and withheld, `false` if the - /// event id was not present in `queues[channel_id]` (race-safe no-op: + /// event id was not present in `queues[scope]` (race-safe no-op: /// the event may have already been drained, removed, or never queued). /// /// Must be called synchronously from the mode-gate fork immediately /// after `pool.send_steer` returns `Ok(())` and before any watcher task /// is spawned, so the withhold is established before `mark_complete` / /// any subsequent `flush_next` tick can run. - pub fn mark_native_steer_pending(&mut self, channel_id: Uuid, event_id: &str) -> bool { - let Some(q) = self.queues.get_mut(&channel_id) else { + pub fn mark_native_steer_pending(&mut self, scope: ConversationScope, event_id: &str) -> bool { + let Some(q) = self.queues.get_mut(&scope) else { return false; }; let Some(pos) = q.iter().position(|qe| qe.event.id.to_hex() == event_id) else { return false; }; - let qe = q - .remove(pos) - .expect("position came from iter so remove must succeed"); + let Some(qe) = q.remove(pos) else { + // Unreachable: position came from iter, so remove must succeed. + return false; + }; 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 } /// Release a single withheld event back to the front of - /// `queues[channel_id]`, preserving its original `received_at`. + /// `queues[scope]`, preserving its original `received_at`. /// /// Called on `SteerAck::Err(_)` and `SteerAck::PromptCompletedNeutral` /// (delivery unknown after prompt completion; restoring queued event @@ -699,9 +733,9 @@ impl EventQueue { /// removed or never withheld. /// /// Push-to-front matches the discipline of `requeue_preserve_timestamps` - /// at line 453, preserving fairness across channels. - pub fn release_native_steer(&mut self, channel_id: Uuid, event_id: &str) { - let Some(entries) = self.withheld_native_steer.get_mut(&channel_id) else { + /// at line 453, preserving fairness across conversations. + pub fn release_native_steer(&mut self, scope: ConversationScope, event_id: &str) { + let Some(entries) = self.withheld_native_steer.get_mut(&scope) else { return; }; let Some(pos) = entries @@ -712,17 +746,17 @@ impl EventQueue { }; let qe = entries.remove(pos); if entries.is_empty() { - self.withheld_native_steer.remove(&channel_id); + self.withheld_native_steer.remove(&scope); } // Push to FRONT so original `received_at` keeps the event at the head - // of the channel's queue. Per-channel cap is enforced below in case - // a flood of events arrived during the ack window. - let queue = self.queues.entry(channel_id).or_default(); + // of the conversation's queue. Per-conversation cap is enforced below + // in case a flood of events arrived during the ack window. + let queue = self.queues.entry(scope).or_default(); queue.push_front(qe); while queue.len() > MAX_PENDING_PER_CHANNEL { queue.pop_back(); tracing::warn!( - channel_id = %channel_id, + scope = %scope, limit = MAX_PENDING_PER_CHANNEL, "release_native_steer overflow — dropped newest event to enforce cap" ); @@ -735,22 +769,22 @@ 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: ConversationScope, event_id: &str) { + if let Some(entries) = self.withheld_native_steer.get_mut(&scope) { entries.retain(|qe| qe.event.id.to_hex() != event_id); if entries.is_empty() { - self.withheld_native_steer.remove(&channel_id); + self.withheld_native_steer.remove(&scope); } } - if let Some(q) = self.queues.get_mut(&channel_id) { + if let Some(q) = self.queues.get_mut(&scope) { q.retain(|qe| qe.event.id.to_hex() != event_id); if q.is_empty() { - self.queues.remove(&channel_id); + self.queues.remove(&scope); } } } - /// Bulk-release every withheld event for `channel_id` back to the queue + /// Bulk-release every withheld event for `scope` back to the queue /// front, preserving relative FIFO order. /// /// Called from the `in_flight_deadline` expiry blocks in @@ -763,25 +797,25 @@ 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: ConversationScope) { + let Some(entries) = self.withheld_native_steer.remove(&scope) else { return; }; let n = entries.len(); - let queue = self.queues.entry(channel_id).or_default(); + let queue = self.queues.entry(scope).or_default(); for qe in entries.into_iter().rev() { queue.push_front(qe); } while queue.len() > MAX_PENDING_PER_CHANNEL { queue.pop_back(); tracing::warn!( - channel_id = %channel_id, + scope = %scope, limit = MAX_PENDING_PER_CHANNEL, "withheld-steer recovery overflow — dropped newest event to enforce cap" ); } tracing::warn!( - channel_id = %channel_id, + scope = %scope, recovered = n, "in-flight expiry recovered withheld steer event(s) — \ steer ack never arrived; normal dispatch will deliver" @@ -791,13 +825,13 @@ impl EventQueue { /// Compact expired metadata entries to prevent unbounded map growth. /// /// Removes `retry_after` entries whose deadline has already passed, and - /// cleans up orphaned `retry_counts` entries for channels that have no - /// queued events, no active throttle, and no in-flight prompt. Without - /// this, channels that completed their retry cycle but never received + /// cleans up orphaned `retry_counts` entries for conversations that have + /// no queued events, no active throttle, and no in-flight prompt. Without + /// this, conversations that completed their retry cycle but never received /// fresh traffic would leak a `u32` entry in `retry_counts` indefinitely. /// - /// The in-flight guard is critical: a channel whose throttle expired and - /// whose queue is empty because it was flushed may still have a retry + /// The in-flight guard is critical: a conversation whose throttle expired + /// and whose queue is empty because it was flushed may still have a retry /// attempt in flight. Removing its `retry_counts` would reset the /// backoff sequence if that attempt fails and requeues. /// @@ -807,13 +841,13 @@ impl EventQueue { pub fn compact_expired_state(&mut self) { let now = Instant::now(); self.retry_after.retain(|_, deadline| *deadline > now); - // Remove retry_counts for channels with no active throttle, no + // Remove retry_counts for conversations 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(|s, _| { + self.retry_after.contains_key(s) + || self.queues.get(s).is_some_and(|q| !q.is_empty()) + || self.in_flight_scopes.contains(s) }); } } @@ -1479,7 +1513,7 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec) -> Vec) -> Vec) -> Vec QueuedEvent { QueuedEvent { - channel_id, + scope: ConversationScope::channel(channel_id), event: make_event(content), received_at: Instant::now(), prompt_tag: "test".into(), @@ -1653,7 +1707,7 @@ mod tests { /// Build a QueuedEvent with a specific `received_at` offset from now. fn make_queued_at(channel_id: Uuid, content: &str, age: Duration) -> QueuedEvent { QueuedEvent { - channel_id, + scope: ConversationScope::channel(channel_id), event: make_event(content), received_at: Instant::now() - age, prompt_tag: "test".into(), @@ -1673,7 +1727,7 @@ mod tests { .sign_with_keys(&keys) .unwrap(); QueuedEvent { - channel_id, + scope: ConversationScope::channel(channel_id), event, received_at: Instant::now(), prompt_tag: "test".into(), @@ -1685,7 +1739,7 @@ mod tests { } fn any_in_flight(q: &EventQueue) -> bool { - !q.in_flight_channels.is_empty() + !q.in_flight_scopes.is_empty() } #[test] @@ -1706,7 +1760,7 @@ mod tests { q.push(make_queued(ch, "hello")); let batch = q.flush_next().expect("should return a batch"); - assert_eq!(batch.channel_id, ch); + assert_eq!(batch.channel_id(), ch); assert_eq!(batch.events.len(), 1); assert_eq!(batch.events[0].event.content, "hello"); @@ -1745,12 +1799,12 @@ mod tests { assert!(q.flush_next().is_none()); // Complete the in-flight prompt. - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); assert!(!any_in_flight(&q)); // Now flush should succeed. let batch = q.flush_next().expect("should flush after mark_complete"); - assert_eq!(batch.channel_id, ch); + assert_eq!(batch.channel_id(), ch); assert_eq!(batch.events.len(), 1); assert_eq!(batch.events[0].event.content, "second"); } @@ -1767,7 +1821,7 @@ mod tests { assert_eq!(pending_count(&q), 3); let batch = q.flush_next().expect("should return batch"); - assert_eq!(batch.channel_id, ch); + assert_eq!(batch.channel_id(), ch); assert_eq!(batch.events.len(), 3); assert_eq!(batch.events[0].event.content, "msg1"); assert_eq!(batch.events[1].event.content, "msg2"); @@ -1818,7 +1872,7 @@ mod tests { let batch = q.flush_next().expect("should return batch"); // A is older, so it should be picked first. - assert_eq!(batch.channel_id, ch_a); + assert_eq!(batch.channel_id(), ch_a); assert_eq!(batch.events[0].event.content, "from A"); } @@ -1834,18 +1888,18 @@ mod tests { // First flush picks A. let batch_a = q.flush_next().expect("first flush"); - assert_eq!(batch_a.channel_id, ch_a); + assert_eq!(batch_a.channel_id(), ch_a); assert!(any_in_flight(&q)); // B still pending. assert_eq!(pending_count(&q), 1); assert_eq!(q.queues.len(), 1); - q.mark_complete(ch_a); + q.mark_complete(ConversationScope::channel(ch_a)); // Second flush picks B. let batch_b = q.flush_next().expect("second flush"); - assert_eq!(batch_b.channel_id, ch_b); + assert_eq!(batch_b.channel_id(), ch_b); assert_eq!(batch_b.events[0].event.content, "B-event"); assert_eq!(pending_count(&q), 0); @@ -1867,7 +1921,7 @@ mod tests { .unwrap_or_else(|_| event.pubkey.to_hex()); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -1897,7 +1951,7 @@ mod tests { fn make_merged_batch(reason: Option) -> FlushBatch { let ch = Uuid::new_v4(); FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event: make_event("the new message"), prompt_tag: "@mention".into(), @@ -1992,7 +2046,7 @@ mod tests { // The mode gate fires Steer → cancel → requeue as cancelled, carrying // the steer reason (exactly the lib.rs requeue path). q.requeue_as_cancelled(batch, CancelReason::Steer); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // The re-prompt the agent actually receives. let merged = q.flush_next().unwrap(); @@ -2028,7 +2082,7 @@ mod tests { // Multi-event header path must also branch on reason. let ch = Uuid::new_v4(); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![ BatchEvent { event: make_event("new one"), @@ -2085,7 +2139,7 @@ mod tests { let _steering_id = steering.id.to_hex(); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event: steering, prompt_tag: "@mention".into(), @@ -2134,10 +2188,10 @@ mod tests { // Simulate failure — requeue the batch. queue.requeue(batch); - queue.mark_complete(ch); + queue.mark_complete(ConversationScope::channel(ch)); // retry_after is set, so manually clear it for this test. - queue.retry_after.remove(&ch); + queue.retry_after.remove(&ConversationScope::channel(ch)); // Should be able to flush again and get the same events in order. let batch2 = queue.flush_next().unwrap(); @@ -2158,15 +2212,15 @@ mod tests { // Flush ch_a first (older). let batch_a = queue.flush_next().unwrap(); - assert_eq!(batch_a.channel_id, ch_a); + assert_eq!(batch_a.channel_id(), ch_a); // Requeue ch_a (simulating failure) and complete. queue.requeue(batch_a); - queue.mark_complete(ch_a); + queue.mark_complete(ConversationScope::channel(ch_a)); // After requeue, ch_a has retry_after set (5s), so ch_b goes first. let next_batch = queue.flush_next().unwrap(); - assert_eq!(next_batch.channel_id, ch_b); + assert_eq!(next_batch.channel_id(), ch_b); } #[test] @@ -2177,7 +2231,7 @@ mod tests { let e3 = make_event("third message"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![ BatchEvent { event: e1, @@ -2217,7 +2271,7 @@ mod tests { let event = make_event("hello"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2240,7 +2294,7 @@ mod tests { let ch = Uuid::new_v4(); let event = make_event("hi"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2272,7 +2326,7 @@ mod tests { let ch = Uuid::new_v4(); let event = make_event("hi"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2302,7 +2356,7 @@ mod tests { let ch = Uuid::new_v4(); let event = make_event("hi"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2329,7 +2383,7 @@ mod tests { let event = make_event("hello"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2353,7 +2407,7 @@ mod tests { let event = make_event("hello"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2409,7 +2463,7 @@ mod tests { let event = make_event("hello"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2447,7 +2501,7 @@ mod tests { let ch = Uuid::new_v4(); let event = make_event("hello"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2513,7 +2567,7 @@ mod tests { q.push(make_queued(ch, "dropped")); assert_eq!(pending_count(&q), 0, "event should be dropped"); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // Nothing to flush. assert!(q.flush_next().is_none()); } @@ -2532,9 +2586,9 @@ mod tests { q.push(make_queued(ch_b, "B-event")); assert_eq!(pending_count(&q), 1); - q.mark_complete(ch_a); + q.mark_complete(ConversationScope::channel(ch_a)); let batch_b = q.flush_next().expect("flush B"); - assert_eq!(batch_b.channel_id, ch_b); + assert_eq!(batch_b.channel_id(), ch_b); } #[test] @@ -2548,22 +2602,22 @@ mod tests { // Flush A — now A is in-flight. let batch_a = q.flush_next().expect("flush A"); - assert_eq!(batch_a.channel_id, ch_a); + assert_eq!(batch_a.channel_id(), ch_a); assert!(any_in_flight(&q)); // Flush B — B should also be flushable (different channel). let batch_b = q.flush_next().expect("flush B while A in-flight"); - assert_eq!(batch_b.channel_id, ch_b); + assert_eq!(batch_b.channel_id(), ch_b); // Both in-flight. - assert_eq!(q.in_flight_channels.len(), 2); + assert_eq!(q.in_flight_scopes.len(), 2); // Complete A only. - q.mark_complete(ch_a); + q.mark_complete(ConversationScope::channel(ch_a)); assert!(any_in_flight(&q)); // B still in-flight. // Complete B. - q.mark_complete(ch_b); + q.mark_complete(ConversationScope::channel(ch_b)); assert!(!any_in_flight(&q)); } @@ -2583,7 +2637,7 @@ mod tests { // flush_next should pick ch2, not ch (ch is in-flight). let batch2 = q.flush_next().expect("should flush ch2"); - assert_eq!(batch2.channel_id, ch2); + assert_eq!(batch2.channel_id(), ch2); // ch still in-flight — no more candidates. assert!(q.flush_next().is_none()); @@ -2607,8 +2661,8 @@ mod tests { q.push(make_queued(ch_b, "B-dropped")); assert_eq!(pending_count(&q), 0); - q.mark_complete(ch_a); - q.mark_complete(ch_b); + q.mark_complete(ConversationScope::channel(ch_a)); + q.mark_complete(ConversationScope::channel(ch_b)); } #[test] @@ -2625,22 +2679,22 @@ mod tests { // Flush A (oldest). let batch = q.flush_next().expect("flush A"); - assert_eq!(batch.channel_id, ch_a); + assert_eq!(batch.channel_id(), ch_a); // A is in-flight; next oldest non-in-flight is B. let batch2 = q.flush_next().expect("flush B"); - assert_eq!(batch2.channel_id, ch_b); + assert_eq!(batch2.channel_id(), ch_b); // A and B in-flight; only C left. let batch3 = q.flush_next().expect("flush C"); - assert_eq!(batch3.channel_id, ch_c); + assert_eq!(batch3.channel_id(), ch_c); // All in-flight. assert!(q.flush_next().is_none()); - q.mark_complete(ch_a); - q.mark_complete(ch_b); - q.mark_complete(ch_c); + q.mark_complete(ConversationScope::channel(ch_a)); + q.mark_complete(ConversationScope::channel(ch_b)); + q.mark_complete(ConversationScope::channel(ch_c)); } #[test] @@ -2655,18 +2709,22 @@ mod tests { let _batch_a = q.flush_next().expect("flush A"); let _batch_b = q.flush_next().expect("flush B"); - assert_eq!(q.in_flight_channels.len(), 2); + assert_eq!(q.in_flight_scopes.len(), 2); // Complete only A. - q.mark_complete(ch_a); - assert_eq!(q.in_flight_channels.len(), 1); - assert!(q.in_flight_channels.contains(&ch_b)); - assert!(!q.in_flight_channels.contains(&ch_a)); + q.mark_complete(ConversationScope::channel(ch_a)); + assert_eq!(q.in_flight_scopes.len(), 1); + assert!(q + .in_flight_scopes + .contains(&ConversationScope::channel(ch_b))); + assert!(!q + .in_flight_scopes + .contains(&ConversationScope::channel(ch_a))); // B still in-flight. assert!(any_in_flight(&q)); - q.mark_complete(ch_b); + q.mark_complete(ConversationScope::channel(ch_b)); assert!(!any_in_flight(&q)); } @@ -2677,7 +2735,7 @@ mod tests { let old_time = Instant::now() - Duration::from_secs(10); q.push(QueuedEvent { - channel_id: ch, + scope: ConversationScope::channel(ch), event: make_event("old-msg"), received_at: old_time, prompt_tag: "test".into(), @@ -2688,7 +2746,7 @@ mod tests { // requeue_preserve_timestamps should keep the original timestamp. q.requeue_preserve_timestamps(batch); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // No retry_after set — should be immediately flushable. let batch2 = q.flush_next().expect("flush after requeue_preserve"); @@ -2704,10 +2762,10 @@ mod tests { let batch = q.flush_next().expect("flush"); q.requeue_preserve_timestamps(batch); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // No retry_after — channel should be immediately flushable. - assert!(!q.retry_after.contains_key(&ch)); + assert!(!q.retry_after.contains_key(&ConversationScope::channel(ch))); assert!(q.flush_next().is_some()); } @@ -2771,7 +2829,7 @@ mod tests { // Requeue — older events go to front, overflow trims from back (newest). q.requeue_preserve_timestamps(batch); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // The requeued events should be at the front of the queue. let batch2 = q.flush_next().expect("should flush after requeue"); @@ -2798,22 +2856,24 @@ mod tests { assert!(!q.has_flushable_work()); // Complete — no pending events, no flushable work. - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); assert!(!q.has_flushable_work()); // Requeue with retry_after — throttled, no flushable work. q.push(make_queued(ch, "msg2")); let batch2 = q.flush_next().expect("flush2"); q.requeue(batch2); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); assert!( !q.has_flushable_work(), "throttled channel should not be flushable" ); // Manually expire the retry_after to simulate time passing. - q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + q.retry_after.insert( + ConversationScope::channel(ch), + Instant::now() - Duration::from_secs(1), + ); assert!( q.has_flushable_work(), "expired throttle should be flushable" @@ -2827,27 +2887,31 @@ 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)); + q.retry_after.insert( + ConversationScope::channel(ch), + Instant::now() - Duration::from_secs(1), + ); let batch = q.flush_next().expect("flush"); assert!( q.requeue(batch).is_none(), "attempt {attempt} should requeue, not dead-letter" ); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); } // The MAX_RETRIES+1'th failure dead-letters: batch is returned. - q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + q.retry_after.insert( + ConversationScope::channel(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.channel_id(), ch); assert_eq!(dead.events.len(), 1); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(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(&ConversationScope::channel(ch))); + assert!(!q.retry_after.contains_key(&ConversationScope::channel(ch))); } #[test] @@ -2861,7 +2925,7 @@ mod tests { // Requeue sets retry_after. q.requeue(batch); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // Channel is throttled — flush_next should return None (no other channels). assert!(q.flush_next().is_none()); @@ -2869,16 +2933,18 @@ mod tests { // Add a different channel — it should be flushable. q.push(make_queued(ch2, "other")); let batch2 = q.flush_next().expect("ch2 should be flushable"); - assert_eq!(batch2.channel_id, ch2); + assert_eq!(batch2.channel_id(), ch2); // After retry_after expires, ch should be flushable again. - q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); - q.mark_complete(ch2); + q.retry_after.insert( + ConversationScope::channel(ch), + Instant::now() - Duration::from_secs(1), + ); + q.mark_complete(ConversationScope::channel(ch2)); let batch3 = q .flush_next() .expect("ch should be flushable after throttle expires"); - assert_eq!(batch3.channel_id, ch); + assert_eq!(batch3.channel_id(), ch); } /// Build an event with specific tags for thread testing. @@ -2964,7 +3030,7 @@ mod tests { let ch = Uuid::new_v4(); let event = make_event("hello"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2995,7 +3061,7 @@ mod tests { let ch = Uuid::new_v4(); let event = make_event("hey"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "dm".into(), @@ -3033,7 +3099,7 @@ mod tests { ]], ); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -3061,7 +3127,7 @@ mod tests { ]], ); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -3105,7 +3171,7 @@ mod tests { let ch = Uuid::new_v4(); let event = make_event("ok do that"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "dm".into(), @@ -3154,7 +3220,7 @@ mod tests { ); let author_hex = event.pubkey.to_hex(); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -3361,7 +3427,7 @@ mod tests { ]], ); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "dm".into(), @@ -3418,7 +3484,7 @@ mod tests { let ch = Uuid::new_v4(); let event = make_event("hey there"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "dm".into(), @@ -3458,7 +3524,7 @@ mod tests { let event = make_event("test"); let event_id = event.id.to_hex(); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -3482,7 +3548,7 @@ mod tests { let hex = event.pubkey.to_hex(); let npub = event.pubkey.to_bech32().unwrap(); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -3505,7 +3571,7 @@ mod tests { // Kind 9 (stream message) — tags were previously stripped. let event = make_event_with_tags("hello", vec![vec!["h".into(), ch.to_string()]]); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -3558,7 +3624,7 @@ mod tests { q.push(make_queued(ch, "msg")); let batch = q.flush_next().unwrap(); q.requeue(batch); // sets retry_after - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // Channel is throttled — verify drain clears it. assert!(!q.has_flushable_work()); @@ -3602,26 +3668,28 @@ mod tests { q.push(make_queued(ch, "msg1")); let batch = q.flush_next().unwrap(); q.requeue(batch); - q.mark_complete(ch); - assert!(q.retry_after.contains_key(&ch)); - assert!(q.retry_counts.contains_key(&ch)); + q.mark_complete(ConversationScope::channel(ch)); + assert!(q.retry_after.contains_key(&ConversationScope::channel(ch))); + assert!(q.retry_counts.contains_key(&ConversationScope::channel(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)); + q.retry_after.insert( + ConversationScope::channel(ch), + Instant::now() - Duration::from_secs(1), + ); let _batch2 = q.flush_next().unwrap(); // Now mark_complete with no active throttle — clears retry_counts. - q.mark_complete(ch); - assert!(!q.retry_counts.contains_key(&ch)); + q.mark_complete(ConversationScope::channel(ch)); + assert!(!q.retry_counts.contains_key(&ConversationScope::channel(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(ConversationScope::channel(ch), 3); q.compact_expired_state(); assert!( - !q.retry_counts.contains_key(&ch), + !q.retry_counts.contains_key(&ConversationScope::channel(ch)), "orphaned retry_counts should be removed" ); } @@ -3635,21 +3703,26 @@ mod tests { q.push(make_queued(ch, "msg1")); let batch = q.flush_next().unwrap(); q.requeue(batch); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // Expire the throttle so the requeued event can be flushed. - q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + q.retry_after.insert( + ConversationScope::channel(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(&ConversationScope::channel(ch))); + assert!(q + .queues + .get(&ConversationScope::channel(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(&ConversationScope::channel(ch)), "retry_counts must survive while channel is in-flight" ); } @@ -3661,11 +3734,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(ConversationScope::channel(ch), 2); q.compact_expired_state(); assert!( - q.retry_counts.contains_key(&ch), + q.retry_counts.contains_key(&ConversationScope::channel(ch)), "retry_counts should survive when queue is non-empty" ); } @@ -3686,7 +3759,7 @@ mod tests { // Cancel the original batch and release the channel. q.requeue_as_cancelled(batch, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // flush_next should merge: events=[new-1], cancelled_events=[old-1, old-2]. let next = q.flush_next().unwrap(); @@ -3708,27 +3781,27 @@ mod tests { let batch = q.flush_next().unwrap(); q.push(make_queued(ch, "new")); q.requeue_as_cancelled(batch, CancelReason::Steer); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); let merged = q.flush_next().unwrap(); assert_eq!( merged.cancel_reason, Some(CancelReason::Steer), "steer reason should reach the merged batch" ); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // Fallback path (no new event): reason still rides through. q.push(make_queued(ch, "only")); let batch = q.flush_next().unwrap(); q.requeue_as_cancelled(batch, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); let fallback = q.flush_next().unwrap(); assert_eq!( fallback.cancel_reason, Some(CancelReason::Interrupt), "interrupt reason should reach the re-dispatched batch" ); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // A normal (non-cancel) flush carries no reason. q.push(make_queued(ch, "plain")); @@ -3744,12 +3817,12 @@ mod tests { let batch1 = q.flush_next().unwrap(); q.push(make_queued(ch, "new-1")); q.requeue_as_cancelled(batch1, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); let batch2 = q.flush_next().unwrap(); // Second cancel with a different reason — the latest reason wins. q.requeue_as_cancelled(batch2, CancelReason::Steer); q.push(make_queued(ch, "new-2")); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); let batch3 = q.flush_next().unwrap(); assert_eq!(batch3.cancel_reason, Some(CancelReason::Steer)); } @@ -3767,7 +3840,7 @@ mod tests { // Cancel the batch (no new events pushed) and release the channel. q.requeue_as_cancelled(batch, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // Fallback path: cancelled events become regular events, cancelled_events is empty. let next = q.flush_next().unwrap(); @@ -3791,7 +3864,7 @@ mod tests { q.push(make_queued(ch, "msg")); let batch = q.flush_next().unwrap(); q.requeue_as_cancelled(batch, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // Channel has only cancelled events — should still be considered flushable. assert!( @@ -3809,7 +3882,7 @@ mod tests { q.push(make_queued(ch, "msg")); let batch = q.flush_next().unwrap(); q.requeue_as_cancelled(batch, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // drain_channel should clear cancelled_batches for the channel. q.drain_channel(ch); @@ -3837,7 +3910,7 @@ mod tests { // First cancel: store 2 cancelled events. q.requeue_as_cancelled(batch1, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // Second flush: events=[new-1], cancelled_events=[orig-1, orig-2]. let batch2 = q.flush_next().unwrap(); @@ -3850,7 +3923,7 @@ mod tests { // Push 1 more new event and release channel. q.push(make_queued(ch, "new-2")); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // Third flush: events=[new-2], cancelled_events=[orig-1, orig-2, new-1]. let batch3 = q.flush_next().unwrap(); @@ -3871,7 +3944,7 @@ mod tests { vec![vec!["e".into(), root_id.clone(), "".into(), "reply".into()]], ); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -3913,7 +3986,7 @@ mod tests { ); let event_id = event.id.to_hex(); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -3947,7 +4020,7 @@ mod tests { let event = make_event("hello world"); let event_id = event.id.to_hex(); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -3976,7 +4049,7 @@ mod tests { let ch = Uuid::new_v4(); let event = make_event("hey there"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -4018,7 +4091,7 @@ mod tests { ); let event_id = event.id.to_hex(); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -4054,7 +4127,7 @@ mod tests { vec![vec!["e".into(), root_id.clone(), "".into(), "reply".into()]], ); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -4089,7 +4162,7 @@ mod tests { vec![vec!["e".into(), root_id.clone(), "".into(), "reply".into()]], ); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![ BatchEvent { event: plain, @@ -4126,7 +4199,7 @@ mod tests { let plain = make_event("latest top-level"); let plain_id = plain.id.to_hex(); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![ BatchEvent { event: threaded, @@ -4159,7 +4232,7 @@ mod tests { /// Build a single-event FlushBatch with the given content. fn make_single_batch(content: &str) -> FlushBatch { FlushBatch { - channel_id: Uuid::new_v4(), + scope: ConversationScope::channel(Uuid::new_v4()), events: vec![BatchEvent { event: make_event(content), prompt_tag: "test".into(), @@ -4283,7 +4356,7 @@ mod tests { let event_id = qe.event.id.to_hex(); q.push(qe); - assert!(q.mark_native_steer_pending(ch, &event_id)); + assert!(q.mark_native_steer_pending(ConversationScope::channel(ch), &event_id)); assert!( q.flush_next().is_none(), @@ -4294,7 +4367,12 @@ 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(&ConversationScope::channel(ch)) + .map(|v| v.len()), + Some(1) + ); } /// Earlier events on the same channel must flush normally during the @@ -4320,25 +4398,25 @@ mod tests { q.push(e3); // Steer in flight for e3 — withhold it from normal dispatch. - assert!(q.mark_native_steer_pending(ch, &e3_id)); + assert!(q.mark_native_steer_pending(ConversationScope::channel(ch), &e3_id)); // Earlier events flush as a normal batch; e3 is invisible. let batch = q .flush_next() .expect("e1+e2 should flush during ack window"); - assert_eq!(batch.channel_id, ch); + assert_eq!(batch.channel_id(), ch); assert_eq!(batch.events.len(), 2); assert_eq!(batch.events[0].event.id.to_hex(), e1_id); assert_eq!(batch.events[1].event.id.to_hex(), e2_id); // Earlier batch completes; channel is no longer in flight. - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // Ack arrives as Err or PromptCompletedNeutral → release e3. - q.release_native_steer(ch, &e3_id); + q.release_native_steer(ConversationScope::channel(ch), &e3_id); let next = q.flush_next().expect("released e3 should now flush"); - assert_eq!(next.channel_id, ch); + assert_eq!(next.channel_id(), ch); assert_eq!(next.events.len(), 1); assert_eq!(next.events[0].event.id.to_hex(), e3_id); @@ -4362,17 +4440,21 @@ mod tests { // Simulate a prompt in flight for `ch`, then withhold the queued // event for an in-flight goose-native steer. - q.in_flight_channels.insert(ch); - q.in_flight_deadlines.insert(ch, Instant::now()); - q.in_flight_batch_sizes.insert(ch, 1); - assert!(q.mark_native_steer_pending(ch, &event_id)); + q.in_flight_scopes.insert(ConversationScope::channel(ch)); + q.in_flight_deadlines + .insert(ConversationScope::channel(ch), Instant::now()); + q.in_flight_batch_sizes + .insert(ConversationScope::channel(ch), 1); + assert!(q.mark_native_steer_pending(ConversationScope::channel(ch), &event_id)); // Force the in-flight deadline to be in the past, simulating the // steer ack never arriving and the read loop hanging long enough // for `in_flight_deadline` to elapse. Same expiry-simulation // trick used by `test_retry_throttle_blocks_requeue_channel`. - q.in_flight_deadlines - .insert(ch, Instant::now() - Duration::from_secs(1)); + q.in_flight_deadlines.insert( + ConversationScope::channel(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. @@ -4389,7 +4471,7 @@ mod tests { let batch = q .flush_next() .expect("recovered event should flush via normal dispatch"); - assert_eq!(batch.channel_id, ch); + assert_eq!(batch.channel_id(), ch); assert_eq!(batch.events.len(), 1); assert_eq!(batch.events[0].event.id.to_hex(), event_id); } @@ -4420,24 +4502,32 @@ mod tests { // tests. What matters here is that the bulk-recovery path // (reverse iter + push_front) composes to original FIFO at the // queue front. - assert!(q.mark_native_steer_pending(ch, &e1_id)); - assert!(q.mark_native_steer_pending(ch, &e2_id)); - assert!(q.mark_native_steer_pending(ch, &e3_id)); + assert!(q.mark_native_steer_pending(ConversationScope::channel(ch), &e1_id)); + assert!(q.mark_native_steer_pending(ConversationScope::channel(ch), &e2_id)); + assert!(q.mark_native_steer_pending(ConversationScope::channel(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(&ConversationScope::channel(ch)) + .map(|v| v.len()), + Some(3) + ); // Trigger expiry → bulk-release path. - q.in_flight_channels.insert(ch); - q.in_flight_deadlines - .insert(ch, Instant::now() - Duration::from_secs(1)); - q.in_flight_batch_sizes.insert(ch, 3); + q.in_flight_scopes.insert(ConversationScope::channel(ch)); + q.in_flight_deadlines.insert( + ConversationScope::channel(ch), + Instant::now() - Duration::from_secs(1), + ); + q.in_flight_batch_sizes + .insert(ConversationScope::channel(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(&ConversationScope::channel(ch)) .expect("queue restored") .iter() .map(|qe| qe.event.id.to_hex()) @@ -4453,7 +4543,7 @@ mod tests { let canvas = "[Channel Canvas]\nCanvas revision (event ID): abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234\nLast modified: 2024-01-15T10:30:00+00:00\nFetch current content with: buzz canvas get --channel 00f1ccaf-1506-4dd7-9a0e-fa67e9e486ae"; let ch = Uuid::new_v4(); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event: make_event("hi"), prompt_tag: "test".into(), @@ -4482,7 +4572,7 @@ mod tests { let canvas = "[Channel Canvas]\nCanvas revision (event ID): abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234\nLast modified: 2024-01-15T10:30:00+00:00\nFetch current content with: buzz canvas get --channel 00f1ccaf-1506-4dd7-9a0e-fa67e9e486ae"; let ch = Uuid::new_v4(); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event: make_event("hi"), prompt_tag: "test".into(), @@ -4510,7 +4600,7 @@ mod tests { fn test_format_prompt_no_canvas_produces_no_canvas_section() { let ch = Uuid::new_v4(); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event: make_event("hi"), prompt_tag: "test".into(), @@ -4559,11 +4649,15 @@ 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(ConversationScope::channel(ch)); + q.in_flight_deadlines + .insert(ConversationScope::channel(ch), old_deadline); - q.extend_in_flight_deadline(ch, 7200); - let new = *q.in_flight_deadlines.get(&ch).unwrap(); + q.extend_in_flight_deadline(ConversationScope::channel(ch), 7200); + let new = *q + .in_flight_deadlines + .get(&ConversationScope::channel(ch)) + .unwrap(); assert!( new > old_deadline, "extended deadline must be past the original" @@ -4575,11 +4669,15 @@ 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(ConversationScope::channel(ch)); + q.in_flight_deadlines + .insert(ConversationScope::channel(ch), far_future); - q.extend_in_flight_deadline(ch, 7200); - let after = *q.in_flight_deadlines.get(&ch).unwrap(); + q.extend_in_flight_deadline(ConversationScope::channel(ch), 7200); + let after = *q + .in_flight_deadlines + .get(&ConversationScope::channel(ch)) + .unwrap(); assert_eq!(after, far_future, "deadline must never move backward"); } @@ -4587,17 +4685,23 @@ 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_deadlines - .insert(ch, Instant::now() + Duration::from_secs(100)); - q.in_flight_batch_sizes.insert(ch, 1); + q.in_flight_scopes.insert(ConversationScope::channel(ch)); + q.in_flight_deadlines.insert( + ConversationScope::channel(ch), + Instant::now() + Duration::from_secs(100), + ); + q.in_flight_batch_sizes + .insert(ConversationScope::channel(ch), 1); - q.mark_complete(ch); - assert!(!q.in_flight_deadlines.contains_key(&ch)); + q.mark_complete(ConversationScope::channel(ch)); + assert!(!q + .in_flight_deadlines + .contains_key(&ConversationScope::channel(ch))); - q.extend_in_flight_deadline(ch, 7200); + q.extend_in_flight_deadline(ConversationScope::channel(ch), 7200); assert!( - !q.in_flight_deadlines.contains_key(&ch), + !q.in_flight_deadlines + .contains_key(&ConversationScope::channel(ch)), "extend after mark_complete must not resurrect a deadline" ); } @@ -4607,17 +4711,21 @@ 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(ConversationScope::channel(ch)); + q.in_flight_deadlines + .insert(ConversationScope::channel(ch), extended); q.compact_expired_state(); assert!( - q.in_flight_deadlines.contains_key(&ch), + q.in_flight_deadlines + .contains_key(&ConversationScope::channel(ch)), "compaction must not touch in-flight deadlines" ); assert_eq!( - *q.in_flight_deadlines.get(&ch).unwrap(), + *q.in_flight_deadlines + .get(&ConversationScope::channel(ch)) + .unwrap(), extended, "compaction must leave extended deadline intact" ); @@ -4636,9 +4744,11 @@ 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(ConversationScope::channel(ch)); + q.in_flight_deadlines + .insert(ConversationScope::channel(ch), Instant::now()); + q.in_flight_batch_sizes + .insert(ConversationScope::channel(ch), 1); // Also push an event so flush_next has something to do after expiry. q.push(make_queued(ch, "after-expiry")); @@ -4648,7 +4758,7 @@ mod tests { let batch = q .flush_next() .expect("channel should be dispatchable after auto-expiry"); - assert_eq!(batch.channel_id, ch); + assert_eq!(batch.channel_id(), ch); assert_eq!(batch.events[0].event.content, "after-expiry"); } @@ -4664,10 +4774,13 @@ 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_deadlines - .insert(ch, Instant::now() + Duration::from_secs(9999)); - q.in_flight_batch_sizes.insert(ch, 1); + q.in_flight_scopes.insert(ConversationScope::channel(ch)); + q.in_flight_deadlines.insert( + ConversationScope::channel(ch), + Instant::now() + Duration::from_secs(9999), + ); + q.in_flight_batch_sizes + .insert(ConversationScope::channel(ch), 1); // Push an event for another channel so flush_next has work to do. let ch2 = Uuid::new_v4(); @@ -4675,17 +4788,19 @@ mod tests { let batch = q.flush_next().expect("other channel should flush"); assert_eq!( - batch.channel_id, ch2, + batch.channel_id(), + ch2, "only ch2 should be flushed; ch is still in-flight with extended deadline" ); // ch must still be in-flight — the extended deadline did not expire. assert!( - q.in_flight_channels.contains(&ch), + q.in_flight_scopes.contains(&ConversationScope::channel(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(&ConversationScope::channel(ch)), "in-flight deadline for ch must not be removed by flush_next" ); } @@ -4702,10 +4817,13 @@ mod tests { let ch = Uuid::new_v4(); // In-flight channel with extended (far-future) deadline. - q.in_flight_channels.insert(ch); - q.in_flight_deadlines - .insert(ch, Instant::now() + Duration::from_secs(9999)); - q.in_flight_batch_sizes.insert(ch, 1); + q.in_flight_scopes.insert(ConversationScope::channel(ch)); + q.in_flight_deadlines.insert( + ConversationScope::channel(ch), + Instant::now() + Duration::from_secs(9999), + ); + q.in_flight_batch_sizes + .insert(ConversationScope::channel(ch), 1); // No other channels — nothing flushable. assert!( @@ -4713,7 +4831,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(&ConversationScope::channel(ch)), "ch must remain in-flight after has_flushable_work with extended deadline" ); @@ -4726,7 +4844,7 @@ mod tests { ); // ch still in-flight and not expired. assert!( - q.in_flight_channels.contains(&ch), + q.in_flight_scopes.contains(&ConversationScope::channel(ch)), "ch must still be in-flight after has_flushable_work finds ch2 work" ); } @@ -4741,15 +4859,23 @@ mod tests { let mut q = EventQueue::new(DedupMode::Queue); let ch = Uuid::new_v4(); - q.in_flight_channels.insert(ch); - q.in_flight_deadlines - .insert(ch, Instant::now() + Duration::from_secs(100)); + q.in_flight_scopes.insert(ConversationScope::channel(ch)); + q.in_flight_deadlines.insert( + ConversationScope::channel(ch), + Instant::now() + Duration::from_secs(100), + ); - q.extend_in_flight_deadline(ch, 7200); - let after_first = *q.in_flight_deadlines.get(&ch).unwrap(); + q.extend_in_flight_deadline(ConversationScope::channel(ch), 7200); + let after_first = *q + .in_flight_deadlines + .get(&ConversationScope::channel(ch)) + .unwrap(); - q.extend_in_flight_deadline(ch, 7200); - let after_second = *q.in_flight_deadlines.get(&ch).unwrap(); + q.extend_in_flight_deadline(ConversationScope::channel(ch), 7200); + let after_second = *q + .in_flight_deadlines + .get(&ConversationScope::channel(ch)) + .unwrap(); assert!( after_second >= after_first, @@ -4757,3 +4883,144 @@ mod tests { ); } } + +#[cfg(test)] +mod thread_scope_isolation_tests { + use super::*; + use crate::config::DedupMode; + use nostr::{EventBuilder, EventId, Keys, Kind}; + + fn make_event(content: &str) -> Event { + let keys = Keys::generate(); + EventBuilder::new(Kind::Custom(9), content) + .tags([]) + .sign_with_keys(&keys) + .unwrap() + } + + fn thread_scope(channel_id: Uuid, root_byte: char) -> ConversationScope { + let root = EventId::from_hex(&root_byte.to_string().repeat(64)).expect("valid hex"); + ConversationScope::thread(channel_id, root) + } + + fn queued(scope: ConversationScope, content: &str) -> QueuedEvent { + QueuedEvent { + scope, + event: make_event(content), + received_at: Instant::now(), + prompt_tag: "test".into(), + } + } + + /// Events for two threads and the channel-level conversation of the SAME + /// channel must never be combined into one batch. + #[test] + fn distinct_scopes_in_one_channel_never_batch_together() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + let thread_a = thread_scope(ch, 'a'); + let thread_b = thread_scope(ch, 'b'); + let channel = ConversationScope::channel(ch); + + q.push(queued(thread_a, "in thread A")); + q.push(queued(thread_b, "in thread B")); + q.push(queued(channel, "top-level")); + + let mut seen = Vec::new(); + while let Some(batch) = q.flush_next() { + assert_eq!( + batch.events.len(), + 1, + "each scope must flush as its own single-event batch" + ); + assert_eq!(batch.channel_id(), ch); + seen.push(batch.scope); + } + seen.sort_by_key(|s| format!("{s}")); + let mut expected = vec![thread_a, thread_b, channel]; + expected.sort_by_key(|s| format!("{s}")); + assert_eq!(seen, expected); + } + + /// An in-flight thread must not block a sibling thread or the channel + /// conversation, and its in-flight state is invisible to their scopes. + #[test] + fn in_flight_thread_does_not_block_siblings() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + let thread_a = thread_scope(ch, 'a'); + let thread_b = thread_scope(ch, 'b'); + let channel = ConversationScope::channel(ch); + + q.push(queued(thread_a, "first")); + let batch_a = q.flush_next().expect("thread A flushes"); + assert_eq!(batch_a.scope, thread_a); + assert!(q.is_scope_in_flight(thread_a)); + assert!(!q.is_scope_in_flight(thread_b)); + assert!(!q.is_scope_in_flight(channel)); + + // Sibling scopes still dispatch while thread A is in flight. + q.push(queued(thread_b, "second")); + q.push(queued(channel, "third")); + let batch_b = q.flush_next().expect("thread B flushes concurrently"); + assert_eq!(batch_b.scope, thread_b); + let batch_c = q.flush_next().expect("channel scope flushes concurrently"); + assert_eq!(batch_c.scope, channel); + + // Thread A itself stays blocked until mark_complete. + q.push(queued(thread_a, "queued behind in-flight")); + assert!(q.flush_next().is_none()); + q.mark_complete(thread_a); + let batch_a2 = q.flush_next().expect("thread A resumes after completion"); + assert_eq!(batch_a2.scope, thread_a); + } + + /// Drop-mode dedup is scope-local: an in-flight thread drops only its own + /// follow-ups, not events for sibling threads in the same channel. + #[test] + fn drop_mode_dedup_is_scope_local() { + let mut q = EventQueue::new(DedupMode::Drop); + let ch = Uuid::new_v4(); + let thread_a = thread_scope(ch, 'a'); + let thread_b = thread_scope(ch, 'b'); + + q.push(queued(thread_a, "first")); + q.flush_next().expect("thread A in flight"); + + assert!( + !q.push(queued(thread_a, "same thread — dropped")), + "drop mode must drop events for the in-flight scope" + ); + assert!( + q.push(queued(thread_b, "sibling thread — accepted")), + "drop mode must NOT drop events for a sibling thread" + ); + assert!( + q.push(queued(ConversationScope::channel(ch), "channel — accepted")), + "drop mode must NOT drop channel-scope events while a thread is in flight" + ); + } + + /// Channel removal must drain every conversation under the channel — + /// channel scope and all thread scopes — and nothing from other channels. + #[test] + fn drain_channel_sweeps_all_scopes_under_channel() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + let other = Uuid::new_v4(); + let thread_a = thread_scope(ch, 'a'); + let thread_b = thread_scope(ch, 'b'); + let other_scope = ConversationScope::channel(other); + + q.push(queued(ConversationScope::channel(ch), "one")); + q.push(queued(thread_a, "two")); + q.push(queued(thread_b, "three")); + q.push(queued(other_scope, "keep")); + + let drained = q.drain_channel(ch); + assert_eq!(drained.len(), 3, "all three conversations drained"); + assert_eq!(q.queued_event_count(&other_scope), 1); + assert!(q.flush_next().is_some(), "other channel still flushable"); + assert!(q.flush_next().is_none()); + } +} diff --git a/crates/buzz-acp/src/scope.rs b/crates/buzz-acp/src/scope.rs new file mode 100644 index 000000000..c2edc3dce --- /dev/null +++ b/crates/buzz-acp/src/scope.rs @@ -0,0 +1,264 @@ +//! Conversation scope — the typed key for ACP session routing. +//! +//! The harness keys session affinity, turn counters, queueing, in-flight +//! tracking, and invalidation by [`ConversationScope`] instead of the bare +//! channel UUID, so separate threads inside one channel get isolated +//! ACP/Hermes sessions (Discord-style thread isolation) while unthreaded +//! channel traffic and DMs keep channel-level continuity. +//! +//! This is agent conversation routing only: channel identity, subscriptions, +//! and NIP-29 authorization remain keyed by the `h`-tag channel UUID. + +use nostr::EventId; +use uuid::Uuid; + +use crate::queue::parse_thread_tags; + +/// The conversation a Buzz event belongs to. +/// +/// Two shapes exist within a channel: +/// +/// - **Channel scope** (`thread_root == None`) — unthreaded channel messages +/// and all DM traffic. One continuous conversation per channel, matching +/// the pre-thread-scoping behavior. +/// - **Thread scope** (`thread_root == Some(root)`) — thread replies in a +/// regular channel. Each canonical NIP-10 thread root gets its own +/// isolated conversation: its own ACP session, turn counter, queue lane, +/// and in-flight slot. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ConversationScope { + /// The channel this conversation lives in (NIP-29 `h` tag UUID). + pub channel_id: Uuid, + /// Canonical thread root event ID, when the conversation is a thread. + pub thread_root: Option, +} + +impl ConversationScope { + /// Channel-level scope: unthreaded messages, DMs, control fallback. + pub fn channel(channel_id: Uuid) -> Self { + Self { + channel_id, + thread_root: None, + } + } + + /// Thread-level scope for a specific canonical root event. + pub fn thread(channel_id: Uuid, root: EventId) -> Self { + Self { + channel_id, + thread_root: Some(root), + } + } + + /// Derive the scope for an inbound event. + /// + /// `thread_scoped` must be `false` for DM channels — and for channels + /// whose type could not be determined — so those keep channel-level + /// continuity. When `true`: + /// + /// - A forum post (kind 45001) IS a thread root: it scopes to its own + /// event ID, so the root post and its later comments share one + /// conversation, while separate forum posts in the same channel stay + /// isolated from each other. + /// - Otherwise the canonical NIP-10 root from the event's `e` tags (via + /// [`parse_thread_tags`], the single thread parser in this crate) + /// selects the thread scope — this covers stream thread replies (kind + /// 9) and forum comments (kind 45003), whose root marker carries the + /// forum post's ID and therefore resolves to the same scope as the + /// post itself. + /// - Events with no root tag, or with a root that is not a valid event + /// ID, fall back to channel scope. + pub fn for_event(channel_id: Uuid, event: &nostr::Event, thread_scoped: bool) -> Self { + if !thread_scoped { + return Self::channel(channel_id); + } + if event.kind.as_u16() as u32 == buzz_core::kind::KIND_FORUM_POST { + return Self::thread(channel_id, event.id); + } + let root = parse_thread_tags(event) + .root_event_id + .as_deref() + .and_then(|hex| EventId::from_hex(hex).ok()); + match root { + Some(root) => Self::thread(channel_id, root), + None => Self::channel(channel_id), + } + } + + /// Whether this scope identifies a thread conversation. + #[cfg(test)] + pub fn is_thread(&self) -> bool { + self.thread_root.is_some() + } +} + +impl std::fmt::Display for ConversationScope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.thread_root { + Some(root) => write!(f, "{}#{}", self.channel_id, root.to_hex()), + None => write!(f, "{}", self.channel_id), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + fn make_event_kind(kind: u16, tags: Vec>) -> nostr::Event { + let keys = Keys::generate(); + let tags: Vec = tags + .into_iter() + .map(|t| Tag::parse(&t).expect("valid tag")) + .collect(); + EventBuilder::new(Kind::Custom(kind), "hello") + .tags(tags) + .sign_with_keys(&keys) + .expect("sign test event") + } + + fn make_event(tags: Vec>) -> nostr::Event { + make_event_kind(9, tags) + } + + fn root_hex() -> String { + "a".repeat(64) + } + + #[test] + fn unthreaded_event_gets_channel_scope() { + let ch = Uuid::new_v4(); + let event = make_event(vec![]); + let scope = ConversationScope::for_event(ch, &event, true); + assert_eq!(scope, ConversationScope::channel(ch)); + assert!(!scope.is_thread()); + } + + #[test] + fn root_marker_selects_thread_scope() { + let ch = Uuid::new_v4(); + let event = make_event(vec![vec!["e".into(), root_hex(), "".into(), "root".into()]]); + let scope = ConversationScope::for_event(ch, &event, true); + let expected_root = EventId::from_hex(&root_hex()).expect("valid hex"); + assert_eq!(scope, ConversationScope::thread(ch, expected_root)); + assert!(scope.is_thread()); + } + + #[test] + fn reply_marker_only_uses_reply_as_canonical_root() { + // parse_thread_tags treats a lone "reply" marker as root == parent. + let ch = Uuid::new_v4(); + let event = make_event(vec![vec![ + "e".into(), + root_hex(), + "".into(), + "reply".into(), + ]]); + let scope = ConversationScope::for_event(ch, &event, true); + let expected_root = EventId::from_hex(&root_hex()).expect("valid hex"); + assert_eq!(scope, ConversationScope::thread(ch, expected_root)); + } + + #[test] + fn root_and_reply_markers_use_root() { + let ch = Uuid::new_v4(); + let parent = "b".repeat(64); + let event = make_event(vec![ + vec!["e".into(), root_hex(), "".into(), "root".into()], + vec!["e".into(), parent, "".into(), "reply".into()], + ]); + let scope = ConversationScope::for_event(ch, &event, true); + let expected_root = EventId::from_hex(&root_hex()).expect("valid hex"); + assert_eq!(scope.thread_root, Some(expected_root)); + } + + #[test] + fn dm_or_unknown_channel_stays_channel_scoped_even_with_thread_tags() { + let ch = Uuid::new_v4(); + let event = make_event(vec![vec!["e".into(), root_hex(), "".into(), "root".into()]]); + let scope = ConversationScope::for_event(ch, &event, false); + assert_eq!(scope, ConversationScope::channel(ch)); + } + + #[test] + fn malformed_root_hex_falls_back_to_channel_scope() { + let ch = Uuid::new_v4(); + let event = make_event(vec![vec![ + "e".into(), + "not-hex".into(), + "".into(), + "root".into(), + ]]); + let scope = ConversationScope::for_event(ch, &event, true); + assert_eq!(scope, ConversationScope::channel(ch)); + } + + #[test] + fn same_thread_same_scope_distinct_threads_distinct_scopes() { + let ch = Uuid::new_v4(); + let root_a = vec!["e".into(), "a".repeat(64), "".into(), "root".into()]; + let root_b = vec!["e".into(), "b".repeat(64), "".into(), "root".into()]; + let a1 = ConversationScope::for_event(ch, &make_event(vec![root_a.clone()]), true); + let a2 = ConversationScope::for_event(ch, &make_event(vec![root_a]), true); + let b = ConversationScope::for_event(ch, &make_event(vec![root_b]), true); + assert_eq!(a1, a2); + assert_ne!(a1, b); + assert_eq!(a1.channel_id, b.channel_id); + } + + #[test] + fn forum_post_scopes_to_its_own_event_id() { + let ch = Uuid::new_v4(); + let post = make_event_kind(45001, vec![]); + let scope = ConversationScope::for_event(ch, &post, true); + assert_eq!(scope, ConversationScope::thread(ch, post.id)); + } + + #[test] + fn forum_comment_resolves_to_the_posts_scope() { + let ch = Uuid::new_v4(); + let post = make_event_kind(45001, vec![]); + let post_scope = ConversationScope::for_event(ch, &post, true); + // A comment (45003) whose canonical root marker names the post. + let comment = make_event_kind( + 45003, + vec![vec!["e".into(), post.id.to_hex(), "".into(), "root".into()]], + ); + let comment_scope = ConversationScope::for_event(ch, &comment, true); + assert_eq!( + comment_scope, post_scope, + "forum root and its comments must share one conversation" + ); + } + + #[test] + fn separate_forum_posts_are_isolated() { + let ch = Uuid::new_v4(); + let post_a = make_event_kind(45001, vec![]); + let post_b = make_event_kind(45001, vec![]); + let scope_a = ConversationScope::for_event(ch, &post_a, true); + let scope_b = ConversationScope::for_event(ch, &post_b, true); + assert_ne!(scope_a, scope_b); + assert_eq!(scope_a.channel_id, scope_b.channel_id); + } + + #[test] + fn forum_post_in_unscoped_channel_stays_channel_scoped() { + let ch = Uuid::new_v4(); + let post = make_event_kind(45001, vec![]); + let scope = ConversationScope::for_event(ch, &post, false); + assert_eq!(scope, ConversationScope::channel(ch)); + } + + #[test] + fn display_includes_root_for_threads() { + let ch = Uuid::new_v4(); + let root = EventId::from_hex(&"c".repeat(64)).expect("valid hex"); + assert_eq!(ConversationScope::channel(ch).to_string(), ch.to_string()); + assert_eq!( + ConversationScope::thread(ch, root).to_string(), + format!("{}#{}", ch, "c".repeat(64)) + ); + } +}