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
+56 -79
View File
@@ -4,9 +4,10 @@
//! When `HUSKIES_UPSTREAM_GATEWAY` is set (or `--upstream-gateway` is passed
//! on the CLI), this module spawns a task that:
//!
//! 1. Acquires `services.perm_rx` for its lifetime (matching the Matrix bot's
//! `permission_listener` pattern), preventing `tool_prompt_permission` from
//! auto-denying requests with "no interactive session".
//! 1. Registers with `services.permission_registry` for its lifetime (matching
//! the Matrix bot's `permission_listener` pattern), preventing
//! `tool_prompt_permission` from auto-denying requests with "no
//! interactive session".
//! 2. Maintains a persistent WebSocket connection to the gateway's
//! `/api/sled-uplink` endpoint.
//! 3. Sends an `identity` frame announcing the sled's project name + auth
@@ -85,9 +86,10 @@ pub struct UplinkEnvelope {
///
/// Does nothing when `config.upstream_url` is empty (AC 8 — sleds without
/// an upstream configured continue to work unchanged). When active, the task
/// holds `services.perm_rx` locked for its lifetime (preventing auto-deny in
/// `tool_prompt_permission`) and forwards all permission requests to the
/// gateway. Reconnects automatically with exponential back-off.
/// registers with `services.permission_registry` for its lifetime (preventing
/// auto-deny in `tool_prompt_permission`) and forwards all permission
/// requests to the gateway. Reconnects automatically with exponential
/// back-off.
pub fn spawn_uplink_task(config: UplinkConfig, services: Arc<Services>) {
if config.upstream_url.is_empty() {
return;
@@ -99,11 +101,12 @@ pub fn spawn_uplink_task(config: UplinkConfig, services: Arc<Services>) {
} = config;
slog!("[uplink] Spawning sled uplink task (gateway={upstream_url}, project={project_name})");
tokio::spawn(async move {
// Acquire perm_rx for this task's entire lifetime. While this lock is
// held, try_lock() inside tool_prompt_permission fails — meaning
// requests flow to perm_tx (which we drain here) rather than auto-deny.
let mut perm_rx = services.perm_rx.lock().await;
slog!("[uplink] Acquired perm_rx; maintaining gateway connection");
// Register for this task's entire lifetime. While registered,
// `permission_registry.is_empty()` in `tool_prompt_permission` returns
// false — meaning requests flow to perm_tx (which we drain here)
// rather than auto-deny.
let (_responder_guard, mut perm_rx) = services.permission_registry.register();
slog!("[uplink] Registered as permission responder; maintaining gateway connection");
let http = reqwest::Client::new();
@@ -143,7 +146,7 @@ async fn run_uplink_session(
project_name: &str,
local_mcp_url: &str,
http: &reqwest::Client,
perm_rx: &mut tokio::sync::mpsc::UnboundedReceiver<PermissionForward>,
perm_rx: &mut tokio::sync::mpsc::Receiver<PermissionForward>,
) -> Result<(), String> {
let (ws_stream, _) = tokio_tungstenite::connect_async(url)
.await
@@ -187,7 +190,7 @@ async fn pump_messages(
impl futures::Stream<Item = Result<WsMessage, tokio_tungstenite::tungstenite::Error>>
+ Unpin
),
perm_rx: &mut tokio::sync::mpsc::UnboundedReceiver<PermissionForward>,
perm_rx: &mut tokio::sync::mpsc::Receiver<PermissionForward>,
in_flight: &mut HashMap<String, oneshot::Sender<PermissionDecision>>,
local_mcp_url: &str,
http: &reqwest::Client,
@@ -401,12 +404,31 @@ fn fail_close_all(in_flight: &mut HashMap<String, oneshot::Sender<PermissionDeci
mod tests {
use super::*;
use crate::http::context::PermissionForward;
use crate::service::permission_router::{PendingPermReplies, ResponderRegistry};
use crate::services::Services;
use std::collections::HashMap;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio_tungstenite::tungstenite::Message as WsMessage;
/// Build a minimal [`Services`] for uplink tests with a fresh permission
/// registry (no router task — tests dispatch directly).
fn test_services() -> Arc<Services> {
let agents = Arc::new(crate::agents::AgentPool::new_test(3000));
Arc::new(Services {
project_root: std::path::PathBuf::from("/tmp"),
status: agents.status_broadcaster(),
agents,
bot_name: "Test".to_string(),
bot_user_id: String::new(),
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
permission_registry: ResponderRegistry::new(),
pending_perm_replies: PendingPermReplies::new(),
permission_timeout_secs: 120,
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
})
}
// ── Pure unit tests ───────────────────────────────────────────────
#[test]
@@ -520,20 +542,7 @@ mod tests {
#[test]
fn spawn_uplink_task_noop_when_url_empty() {
let (_perm_tx, perm_rx) = tokio::sync::mpsc::unbounded_channel();
let agents = Arc::new(crate::agents::AgentPool::new_test(3000));
let services = Arc::new(Services {
project_root: std::path::PathBuf::from("/tmp"),
status: agents.status_broadcaster(),
agents,
bot_name: "Test".to_string(),
bot_user_id: String::new(),
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)),
pending_perm_replies: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
permission_timeout_secs: 120,
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
});
let services = test_services();
// Empty URL → noop; if it panicked or blocked the test would fail.
spawn_uplink_task(
UplinkConfig {
@@ -592,20 +601,7 @@ mod tests {
.unwrap();
});
let (perm_tx, perm_rx) = tokio::sync::mpsc::unbounded_channel::<PermissionForward>();
let agents = Arc::new(crate::agents::AgentPool::new_test(3000));
let services = Arc::new(Services {
project_root: std::path::PathBuf::from("/tmp"),
status: agents.status_broadcaster(),
agents,
bot_name: "Test".to_string(),
bot_user_id: String::new(),
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)),
pending_perm_replies: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
permission_timeout_secs: 120,
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
});
let services = test_services();
spawn_uplink_task(
UplinkConfig {
@@ -618,14 +614,12 @@ mod tests {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let (response_tx, response_rx) = oneshot::channel();
perm_tx
.send(PermissionForward {
request_id: "req-test-1".to_string(),
tool_name: "Bash".to_string(),
tool_input: serde_json::json!({"command": "echo hello"}),
response_tx,
})
.unwrap();
services.permission_registry.dispatch(PermissionForward {
request_id: "req-test-1".to_string(),
tool_name: "Bash".to_string(),
tool_input: serde_json::json!({"command": "echo hello"}),
response_tx,
});
let decision = tokio::time::timeout(std::time::Duration::from_secs(5), response_rx)
.await
@@ -690,20 +684,7 @@ mod tests {
}
});
let (perm_tx, perm_rx) = tokio::sync::mpsc::unbounded_channel::<PermissionForward>();
let agents = Arc::new(crate::agents::AgentPool::new_test(3000));
let services = Arc::new(Services {
project_root: std::path::PathBuf::from("/tmp"),
status: agents.status_broadcaster(),
agents,
bot_name: "Test".to_string(),
bot_user_id: String::new(),
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)),
pending_perm_replies: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
permission_timeout_secs: 120,
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
});
let services = test_services();
spawn_uplink_task(
UplinkConfig {
@@ -717,14 +698,12 @@ mod tests {
// First request is sent on the connection that drops → denied.
let (tx1, rx1) = oneshot::channel();
perm_tx
.send(PermissionForward {
request_id: "req-drop".to_string(),
tool_name: "Bash".to_string(),
tool_input: serde_json::json!({}),
response_tx: tx1,
})
.unwrap();
services.permission_registry.dispatch(PermissionForward {
request_id: "req-drop".to_string(),
tool_name: "Bash".to_string(),
tool_input: serde_json::json!({}),
response_tx: tx1,
});
let d1 = tokio::time::timeout(std::time::Duration::from_secs(5), rx1)
.await
@@ -741,14 +720,12 @@ mod tests {
// Second request arrives on the reconnected session → approved.
let (tx2, rx2) = oneshot::channel();
perm_tx
.send(PermissionForward {
request_id: "req-reconnect".to_string(),
tool_name: "Write".to_string(),
tool_input: serde_json::json!({}),
response_tx: tx2,
})
.unwrap();
services.permission_registry.dispatch(PermissionForward {
request_id: "req-reconnect".to_string(),
tool_name: "Write".to_string(),
tool_input: serde_json::json!({}),
response_tx: tx2,
});
let d2 = tokio::time::timeout(std::time::Duration::from_secs(5), rx2)
.await