huskies: merge 1163 story Replace perm_rx lock-as-presence-signal with a permission router

This commit is contained in:
Huskies Agent
2026-07-16 13:19:20 +00:00
parent 6f8a8ffd87
commit 18b065f77a
26 changed files with 823 additions and 293 deletions
@@ -81,14 +81,14 @@ pub(super) async fn handle_llm_message(
);
tokio::pin!(chat_fut);
// Lock the permission receiver for the duration of this chat session.
let mut perm_rx_guard = ctx.services.perm_rx.lock().await;
// Register as a permission responder for the duration of this chat turn.
let (_perm_guard, mut perm_rx) = ctx.services.permission_registry.register();
let result = loop {
tokio::select! {
r = &mut chat_fut => break r,
Some(perm_fwd) = perm_rx_guard.recv() => {
Some(perm_fwd) = perm_rx.recv() => {
let prompt_msg = format!(
"*Permission Request*\n\nTool: `{}`\n```json\n{}\n```\n\nReply *yes* to approve or *no* to deny.",
perm_fwd.tool_name,
@@ -101,20 +101,22 @@ pub(super) async fn handle_llm_message(
}
// Store the response sender so the incoming message handler
// can resolve it when the user replies yes/no.
// can resolve it when the user replies yes/no. Keyed by
// request_id (not just sender) so a second concurrent
// request doesn't drop the first's sender.
ctx.services.pending_perm_replies
.lock()
.await
.insert(sender.to_string(), perm_fwd.response_tx);
.insert(sender.to_string(), perm_fwd.request_id.clone(), perm_fwd.response_tx)
.await;
// Spawn a timeout task: auto-deny if the user does not respond.
let pending = Arc::clone(&ctx.services.pending_perm_replies);
let timeout_sender = sender.to_string();
let timeout_request_id = perm_fwd.request_id.clone();
let timeout_transport = Arc::clone(&ctx.transport);
let timeout_secs = ctx.services.permission_timeout_secs;
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(timeout_secs)).await;
if let Some(tx) = pending.lock().await.remove(&timeout_sender) {
if let Some(tx) = pending.remove_by_request_id(&timeout_sender, &timeout_request_id).await {
let _ = tx.send(PermissionDecision::Deny);
let msg = "Permission request timed out — denied (fail-closed).";
let _ = timeout_transport.send_message(&timeout_sender, msg, "").await;
@@ -123,7 +125,6 @@ pub(super) async fn handle_llm_message(
}
}
};
drop(perm_rx_guard);
// Flush remaining text.
let remaining = buffer.lock().unwrap().trim().to_string();
@@ -32,8 +32,12 @@ pub(super) async fn handle_incoming_message(
// If there is a pending permission prompt for this sender, interpret the
// message as a yes/no response instead of starting a new command/LLM flow.
{
let mut pending = ctx.services.pending_perm_replies.lock().await;
if let Some(tx) = pending.remove(sender) {
if let Some(tx) = ctx
.services
.pending_perm_replies
.resolve_oldest(sender)
.await
{
let decision = if is_permission_approval(message) {
PermissionDecision::Approve
} else {
@@ -279,7 +283,6 @@ mod tests {
let (tx, _rx) = tokio::sync::broadcast::channel::<WatcherEvent>(16);
let agents = Arc::new(AgentPool::new(3999, tx));
let tracker = Arc::new(MessagingWindowTracker::new());
let (_perm_tx, perm_rx) = tokio::sync::mpsc::unbounded_channel();
let services = Arc::new(crate::services::Services {
project_root: tmp.path().to_path_buf(),
status: agents.status_broadcaster(),
@@ -287,8 +290,8 @@ mod tests {
bot_name: "Bot".to_string(),
bot_user_id: "whatsapp-bot".to_string(),
ambient_rooms: Arc::new(std::sync::Mutex::new(Default::default())),
perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)),
pending_perm_replies: Arc::new(tokio::sync::Mutex::new(Default::default())),
permission_registry: crate::service::permission_router::ResponderRegistry::new(),
pending_perm_replies: crate::service::permission_router::PendingPermReplies::new(),
permission_timeout_secs: 120,
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
});