agentmux_srv/ambient/limits.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! How many ambient CLI calls may run at once. `AmbientGateway` coalesces and
5//! cancels only within one `(entity, purpose)` key; without a cap, N panes
6//! finishing a turn at the same moment would each spawn their own CLI.
7//!
8//! Two classes, each with one cap, so the total is bounded and a burst of
9//! background work can never hold up a call the user is waiting on. These
10//! replace six separate semaphores (pull calls, definition previews, backlog
11//! naming, narration, the recovery sweep's own, and none at all for the
12//! continuity summary), each justified alone, none bounding the total.
13//! docs/reports/REPORT_AMBIENT_FRAMEWORK_REASSESSMENT_2026_10_08.md section 6.4.
14
15use std::time::Duration;
16
17use tokio::sync::Semaphore;
18
19/// Which queue an ambient call waits in.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Class {
22 /// Shown to the user as a result of what they just did: the pane's title, the
23 /// next-message suggestion, a name for a row they opened or a subagent that
24 /// just started.
25 Interactive,
26 /// Fill-in nobody is waiting on: title recovery, previews, backlog naming,
27 /// narration, the continuity summary.
28 Background,
29}
30
31/// Simultaneous interactive calls, across all panes.
32pub const INTERACTIVE_CAP: usize = 2;
33/// Simultaneous background calls, across all panes. One more than interactive:
34/// a continuity summary can run for up to 90 s, and narration should not wait
35/// behind two of them.
36pub const BACKGROUND_CAP: usize = 3;
37
38/// The longest an interactive call waits for a permit before giving up. With
39/// the 30 s interactive time limit (`purpose`), a call ends within 40 s, inside
40/// the pane's 45 s RPC timeout (`ambient-rpc.ts`), so a reply is never paid for
41/// after the pane has stopped waiting for it.
42pub const INTERACTIVE_MAX_QUEUE_WAIT: Duration = Duration::from_secs(10);
43
44impl Class {
45 /// How long a call of this class may wait for a permit; `None` for no limit
46 /// (nobody is waiting on background work).
47 pub fn max_queue_wait(self) -> Option<Duration> {
48 match self {
49 Class::Interactive => Some(INTERACTIVE_MAX_QUEUE_WAIT),
50 Class::Background => None,
51 }
52 }
53
54 /// The class's process-wide semaphore.
55 pub fn semaphore(self) -> &'static Semaphore {
56 static INTERACTIVE: std::sync::OnceLock<Semaphore> = std::sync::OnceLock::new();
57 static BACKGROUND: std::sync::OnceLock<Semaphore> = std::sync::OnceLock::new();
58 match self {
59 Class::Interactive => INTERACTIVE.get_or_init(|| Semaphore::new(INTERACTIVE_CAP)),
60 Class::Background => BACKGROUND.get_or_init(|| Semaphore::new(BACKGROUND_CAP)),
61 }
62 }
63}
64
65#[cfg(test)]
66mod tests {
67 use super::*;
68
69 #[test]
70 fn each_class_is_capped_and_the_two_are_separate() {
71 let interactive = Class::Interactive.semaphore();
72 let background = Class::Background.semaphore();
73 assert!(!std::ptr::eq(interactive, background), "background work must not queue behind interactive work");
74 // Capacity, read without taking permits other tests might be holding.
75 assert!(interactive.available_permits() <= INTERACTIVE_CAP);
76 assert!(background.available_permits() <= BACKGROUND_CAP);
77 assert!(std::ptr::eq(interactive, Class::Interactive.semaphore()), "one semaphore per class, process-wide");
78 }
79}