huskies: merge 1093 bug Chat dispatcher spawns one Timmy per inbound message — needs coalesce window + per-session serial lock
This commit is contained in:
@@ -268,6 +268,7 @@ mod tests {
|
||||
pending_perm_replies: Arc::new(TokioMutex::new(HashMap::new())),
|
||||
permission_timeout_secs: 120,
|
||||
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
|
||||
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
|
||||
ctx: BotContext,
|
||||
sender: String,
|
||||
user_message: String,
|
||||
mut cancel_rx: watch::Receiver<bool>,
|
||||
) {
|
||||
// Look up the room's existing Claude Code session ID (if any) so we can
|
||||
// resume the conversation with structured API messages instead of
|
||||
@@ -68,9 +69,6 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
|
||||
);
|
||||
|
||||
let provider = ClaudeCodeProvider::new();
|
||||
let (cancel_tx, mut cancel_rx) = watch::channel(false);
|
||||
// Keep the sender alive for the duration of the call.
|
||||
let _cancel_tx = cancel_tx;
|
||||
|
||||
// Channel for sending complete paragraphs to the Matrix posting task.
|
||||
let (msg_tx, mut msg_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
|
||||
@@ -608,9 +608,56 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
||||
return;
|
||||
}
|
||||
|
||||
// Spawn a separate task so the Matrix sync loop is not blocked while we
|
||||
// wait for the LLM response (which can take several seconds).
|
||||
tokio::spawn(async move {
|
||||
handle_message(room_id_str, incoming_room_id, ctx, sender, user_message).await;
|
||||
});
|
||||
// "stop" — cancel the running LLM turn for this session and clear pending queue.
|
||||
{
|
||||
let stripped = crate::chat::util::strip_bot_mention(
|
||||
&user_message,
|
||||
&ctx.services.bot_name,
|
||||
ctx.matrix_user_id.as_str(),
|
||||
)
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if stripped == "stop" {
|
||||
slog!("[matrix-bot] stop command from {sender} for session {room_id_str}");
|
||||
ctx.services.chat_dispatcher.stop(&room_id_str);
|
||||
let msg = "Stopped.";
|
||||
let html = markdown_to_html(msg);
|
||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, msg, &html).await
|
||||
&& let Ok(event_id) = msg_id.parse()
|
||||
{
|
||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Hand the message to the protocol-agnostic dispatcher instead of spawning
|
||||
// directly. The dispatcher applies a coalesce window and a per-session
|
||||
// serial lock, preventing duplicate concurrent Timmy spawns.
|
||||
let ctx_for_factory = ctx.clone();
|
||||
let factory: crate::chat::dispatcher::SpawnFn = {
|
||||
let room_id_str2 = room_id_str.clone();
|
||||
std::sync::Arc::new(
|
||||
move |coalesced: String, cancel_rx: tokio::sync::watch::Receiver<bool>| {
|
||||
let room_id_str = room_id_str2.clone();
|
||||
let incoming_room_id = incoming_room_id.clone();
|
||||
let ctx = ctx_for_factory.clone();
|
||||
let sender = sender.clone();
|
||||
Box::pin(async move {
|
||||
handle_message(
|
||||
room_id_str,
|
||||
incoming_room_id,
|
||||
ctx,
|
||||
sender,
|
||||
coalesced,
|
||||
cancel_rx,
|
||||
)
|
||||
.await;
|
||||
})
|
||||
},
|
||||
)
|
||||
};
|
||||
ctx.services
|
||||
.chat_dispatcher
|
||||
.submit(room_id_str, user_message, factory);
|
||||
}
|
||||
|
||||
@@ -150,6 +150,7 @@ mod tests {
|
||||
pending_perm_replies: Arc::new(TokioMutex::new(HashMap::new())),
|
||||
permission_timeout_secs: 120,
|
||||
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
|
||||
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
||||
});
|
||||
(services, perm_tx)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ pub(super) fn default_aggregated_notifications_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Default coalesce window for the chat dispatcher (1 500 ms).
|
||||
pub(super) fn default_coalesce_window_ms() -> u64 {
|
||||
1_500
|
||||
}
|
||||
|
||||
pub(super) fn default_transport() -> String {
|
||||
"matrix".to_string()
|
||||
}
|
||||
@@ -187,4 +192,14 @@ pub struct BotConfig {
|
||||
/// Defaults to `true`.
|
||||
#[serde(default = "default_aggregated_notifications_enabled")]
|
||||
pub aggregated_notifications_enabled: bool,
|
||||
|
||||
/// Duration in milliseconds of the chat dispatcher's coalesce window.
|
||||
///
|
||||
/// Messages for the same session arriving within this window are
|
||||
/// concatenated into a single `claude -p` call. The window is a
|
||||
/// debounce: each new message extends the deadline by this duration.
|
||||
///
|
||||
/// Defaults to 1 500 ms (1.5 s).
|
||||
#[serde(default = "default_coalesce_window_ms")]
|
||||
pub coalesce_window_ms: u64,
|
||||
}
|
||||
|
||||
@@ -310,6 +310,7 @@ mod tests {
|
||||
perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)),
|
||||
pending_perm_replies: Arc::new(tokio::sync::Mutex::new(Default::default())),
|
||||
permission_timeout_secs: 120,
|
||||
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
||||
});
|
||||
Arc::new(WhatsAppWebhookContext {
|
||||
services,
|
||||
|
||||
Reference in New Issue
Block a user