huskies: merge 1163 story Replace perm_rx lock-as-presence-signal with a permission router
This commit is contained in:
+56
-79
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user