huskies: merge 1093 bug Chat dispatcher spawns one Timmy per inbound message — needs coalesce window + per-session serial lock

This commit is contained in:
dave
2026-05-15 11:57:00 +00:00
parent 01e60a670c
commit fc5481dbe4
15 changed files with 466 additions and 8 deletions
@@ -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,