huskies: merge 1163 story Replace perm_rx lock-as-presence-signal with a permission router
This commit is contained in:
@@ -96,6 +96,16 @@ impl AppContext {
|
||||
let (watcher_tx, _) = broadcast::channel(64);
|
||||
let (reconciliation_tx, _) = broadcast::channel(64);
|
||||
let (perm_tx, perm_rx) = mpsc::unbounded_channel();
|
||||
let permission_registry = crate::service::permission_router::ResponderRegistry::new();
|
||||
// Plain `#[test]` fns (no tokio runtime) construct `AppContext` too;
|
||||
// skip spawning when there's no reactor to spawn onto since those
|
||||
// tests never exercise the permission plumbing.
|
||||
if tokio::runtime::Handle::try_current().is_ok() {
|
||||
crate::service::permission_router::spawn_permission_router(
|
||||
perm_rx,
|
||||
Arc::clone(&permission_registry),
|
||||
);
|
||||
}
|
||||
let timer_store = Arc::new(TimerStore::load(
|
||||
project_root.join(".huskies").join("timers.json"),
|
||||
));
|
||||
@@ -112,10 +122,8 @@ impl AppContext {
|
||||
bot_name: "Assistant".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(
|
||||
std::collections::HashMap::new(),
|
||||
)),
|
||||
permission_registry,
|
||||
pending_perm_replies: crate::service::permission_router::PendingPermReplies::new(),
|
||||
permission_timeout_secs: 120,
|
||||
status: agents.status_broadcaster(),
|
||||
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
||||
|
||||
@@ -24,7 +24,7 @@ const GATEWAY_TOOLS: &[&str] = &[
|
||||
"adopt_project",
|
||||
"aggregate_pipeline_status",
|
||||
"agents.list",
|
||||
// Handled at the gateway so the Matrix bot's perm_rx listener is used
|
||||
// Handled at the gateway so the Matrix bot's permission listener is used
|
||||
// rather than the container's (which has no interactive session attached).
|
||||
"prompt_permission",
|
||||
// One-shot container rebuild: build fresh image, swap container, preserve state.
|
||||
@@ -764,9 +764,9 @@ async fn handle_aggregate_pipeline_status_tool(
|
||||
/// Handle the `prompt_permission` tool at the gateway level.
|
||||
///
|
||||
/// Mirrors `tool_prompt_permission` in `http/mcp/diagnostics/permission.rs` but
|
||||
/// uses the gateway's `perm_tx`/`perm_rx` so requests reach the Matrix bot that
|
||||
/// is listening on the gateway, not the proxied container (which has no
|
||||
/// interactive session and would auto-deny immediately).
|
||||
/// uses the gateway's `perm_tx`/`permission_registry` so requests reach the
|
||||
/// Matrix bot that is listening on the gateway, not the proxied container
|
||||
/// (which has no interactive session and would auto-deny immediately).
|
||||
async fn handle_prompt_permission_tool(
|
||||
params: &Value,
|
||||
state: &GatewayState,
|
||||
@@ -792,9 +792,9 @@ async fn handle_prompt_permission_tool(
|
||||
return JsonRpcResponse::success(id, json!({"content": [{"type": "text", "text": text}]}));
|
||||
}
|
||||
|
||||
// Auto-deny when no interactive session holds perm_rx (i.e. no Matrix bot
|
||||
// listener is running — try_lock succeeds when nobody else holds the lock).
|
||||
if state.perm_rx.try_lock().is_ok() {
|
||||
// Auto-deny when no responder is registered (i.e. no Matrix bot listener
|
||||
// is running).
|
||||
if state.permission_registry.is_empty() {
|
||||
crate::slog!("[gateway/permission] Auto-denied '{tool_name}' (no interactive session)");
|
||||
let text = json!({
|
||||
"behavior": "deny",
|
||||
|
||||
@@ -29,14 +29,14 @@ pub(crate) async fn tool_prompt_permission(
|
||||
return Ok(json!({"behavior": "allow", "updatedInput": tool_input}).to_string());
|
||||
}
|
||||
|
||||
// Auto-deny immediately if no interactive session is currently listening on
|
||||
// perm_rx. Story 884 made the Matrix bot hold this lock for its lifetime
|
||||
// via the permission_listener task spawned at startup, so requests reach
|
||||
// chat asynchronously regardless of whether a chat message is in flight.
|
||||
// Other transports (Discord/Slack/WhatsApp) still acquire per message; if
|
||||
// none is active, try_lock succeeds — auto-deny so background agent calls
|
||||
// don't queue and flood chat at the next user session.
|
||||
if ctx.services.perm_rx.try_lock().is_ok() {
|
||||
// Auto-deny immediately if no responder is currently registered to
|
||||
// receive forwarded permission requests. The Matrix bot's
|
||||
// permission_listener task, sled uplinks, and per-message chat transports
|
||||
// all register for the duration they're able to prompt a user; if none is
|
||||
// registered, don't forward the request into the void — auto-deny so
|
||||
// background agent calls don't queue and flood chat at the next user
|
||||
// session.
|
||||
if ctx.services.permission_registry.is_empty() {
|
||||
crate::slog!(
|
||||
"[permission] Auto-denied '{tool_name}' (no interactive session — agent mode)"
|
||||
);
|
||||
@@ -141,24 +141,19 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
// Simulate an interactive session: lock perm_rx first, signal readiness,
|
||||
// then respond with approval. The try_lock() inside tool_prompt_permission
|
||||
// must fail (lock held) so the request is forwarded rather than auto-denied.
|
||||
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let perm_rx = ctx.services.perm_rx.clone();
|
||||
// Simulate an interactive session: register a responder first so the
|
||||
// registry is non-empty and the request is forwarded rather than
|
||||
// auto-denied, then respond with approval.
|
||||
let (guard, mut rx) = ctx.services.permission_registry.register();
|
||||
tokio::spawn(async move {
|
||||
let mut rx = perm_rx.lock().await;
|
||||
let _ = ready_tx.send(()); // signal: lock is held
|
||||
if let Some(forward) = rx.recv().await {
|
||||
let _ = forward
|
||||
.response_tx
|
||||
.send(crate::http::context::PermissionDecision::Approve);
|
||||
}
|
||||
drop(guard);
|
||||
});
|
||||
|
||||
// Wait until the spawned task holds the perm_rx lock.
|
||||
ready_rx.await.unwrap();
|
||||
|
||||
let result = tool_prompt_permission(
|
||||
&json!({"tool_name": "Bash", "input": {"command": "echo hello"}}),
|
||||
&ctx,
|
||||
@@ -182,22 +177,17 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
// Simulate an interactive session: lock perm_rx first, then deny.
|
||||
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let perm_rx = ctx.services.perm_rx.clone();
|
||||
// Simulate an interactive session: register a responder, then deny.
|
||||
let (guard, mut rx) = ctx.services.permission_registry.register();
|
||||
tokio::spawn(async move {
|
||||
let mut rx = perm_rx.lock().await;
|
||||
let _ = ready_tx.send(()); // signal: lock is held
|
||||
if let Some(forward) = rx.recv().await {
|
||||
let _ = forward
|
||||
.response_tx
|
||||
.send(crate::http::context::PermissionDecision::Deny);
|
||||
}
|
||||
drop(guard);
|
||||
});
|
||||
|
||||
// Wait until the spawned task holds the perm_rx lock.
|
||||
ready_rx.await.unwrap();
|
||||
|
||||
let result = tool_prompt_permission(&json!({"tool_name": "Write", "input": {}}), &ctx)
|
||||
.await
|
||||
.expect("denial must return Ok, not Err");
|
||||
|
||||
@@ -147,34 +147,20 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
|
||||
);
|
||||
tokio::pin!(chat_fut);
|
||||
|
||||
// Take perm_rx for local permission forwarding — but never
|
||||
// block on it. Since story 884 the Matrix permission
|
||||
// listener (and the sled uplink, when configured) hold this
|
||||
// lock for the process lifetime, so a blocking
|
||||
// `lock().await` here parks the entire WS connection loop
|
||||
// forever: chat_fut is never polled, RPC frames on this
|
||||
// socket are never answered, and everything queues behind
|
||||
// the dispatcher's serial session lock. If another task
|
||||
// already owns permission routing, proceed without the
|
||||
// local forwarding arm — requests are handled there.
|
||||
let mut perm_rx = ctx.services.perm_rx.try_lock().ok();
|
||||
if perm_rx.is_none() {
|
||||
crate::slog!(
|
||||
"[ws] perm_rx held by another listener; \
|
||||
skipping local permission forwarding for this chat"
|
||||
);
|
||||
}
|
||||
// Register as a permission responder for the duration of
|
||||
// this chat turn so local (browser) approval works
|
||||
// alongside any other registered responder (Matrix bot,
|
||||
// sled uplink). Registration and its private channel never
|
||||
// block on foreign I/O, so this can never park the WS
|
||||
// connection loop — unlike the old `perm_rx.lock().await`
|
||||
// workaround from commit ba0a38d4, which this replaces.
|
||||
let (_perm_guard, mut perm_rx) = ctx.services.permission_registry.register();
|
||||
|
||||
let chat_result = loop {
|
||||
tokio::select! {
|
||||
result = &mut chat_fut => break result,
|
||||
|
||||
Some(perm_fwd) = async {
|
||||
match perm_rx.as_mut() {
|
||||
Some(rx) => rx.recv().await,
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
} => {
|
||||
Some(perm_fwd) = perm_rx.recv() => {
|
||||
let _ = tx.send(ws::permission_request_response(
|
||||
&perm_fwd.request_id,
|
||||
&perm_fwd.tool_name,
|
||||
|
||||
Reference in New Issue
Block a user