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
+16 -26
View File
@@ -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");