@@ -69,6 +69,38 @@ const DEFAULT_MAX_PROCESS_HTTP2_EVENT_BYTES: usize = 512 * 1024 * 1024;
6969const DEFAULT_TASK_POLL_WATCHDOG_MS : u64 = 100 ;
7070const DEFAULT_MAX_TERMINAL_TASK_REPORTS : usize = 4_096 ;
7171const DEFAULT_VM_EXECUTOR_TEARDOWN_TIMEOUT_MS : u64 = 5_000 ;
72+
73+ /// Process-wide ceiling on concurrently running guest executions.
74+ ///
75+ /// Each admitted execution owns one OS thread and one V8 isolate (thread-affine,
76+ /// so it can never be multiplexed onto a shared pool) capped at
77+ /// `DEFAULT_HEAP_LIMIT_MB`. The binding constraint is therefore threads and
78+ /// memory, NOT CPU: an agent parked on a network read, or a shell blocked in
79+ /// `waitpid`, burns no CPU and still holds its slot for the whole life of the
80+ /// guest process. Deriving this from `available_parallelism()` made the ceiling
81+ /// depend on the host's core count and silently rejected ordinary workloads —
82+ /// a shell plus the command it waits on already needs two slots.
83+ ///
84+ /// A fixed default keeps the admitted concurrency identical on every host.
85+ /// Raise it with `AGENTOS_MAX_ACTIVE_GUEST_EXECUTIONS`.
86+ pub const DEFAULT_MAX_ACTIVE_GUEST_EXECUTIONS : usize = 64 ;
87+
88+ /// Hard ceiling for `AGENTOS_MAX_ACTIVE_GUEST_EXECUTIONS`.
89+ ///
90+ /// Admission stays bounded regardless of what an operator asks for: at this
91+ /// ceiling the process still reserves 1024 OS threads and isolates, which is
92+ /// past the point where a host is thread- and memory-bound. Requests above it
93+ /// are a typed configuration error, never a silent clamp.
94+ pub const MAX_ACTIVE_GUEST_EXECUTIONS_CEILING : usize = 1_024 ;
95+
96+ /// Operator override for [`DEFAULT_MAX_ACTIVE_GUEST_EXECUTIONS`].
97+ ///
98+ /// Read once, by the process entrypoint, before any VM exists. The value is
99+ /// process topology (see [`SidecarRuntime::process`]) and is deliberately not a
100+ /// client wire field: one sidecar process is shared by every VM and connection,
101+ /// so no single tenant may rewrite it for its neighbours.
102+ pub const MAX_ACTIVE_GUEST_EXECUTIONS_ENV : & str = "AGENTOS_MAX_ACTIVE_GUEST_EXECUTIONS" ;
103+
72104pub const DEFAULT_PROTOCOL_MAX_INGRESS_FRAMES : usize = 128 ;
73105pub const DEFAULT_PROTOCOL_MAX_INGRESS_BYTES : usize = 64 * 1024 * 1024 ;
74106pub const DEFAULT_PROTOCOL_MAX_CONTROL_FRAMES : usize = 1_024 ;
@@ -417,7 +449,10 @@ impl RuntimeResourceConfig {
417449#[ derive( Clone , Debug , PartialEq , Eq ) ]
418450pub struct RuntimeConfig {
419451 pub worker_threads : usize ,
420- pub max_active_vm_executors : usize ,
452+ /// Process-wide cap on concurrently running guest executions (JavaScript,
453+ /// TypeScript, Python, and WASM alike — every live guest process holds one).
454+ /// See [`DEFAULT_MAX_ACTIVE_GUEST_EXECUTIONS`].
455+ pub max_active_guest_executions : usize ,
421456 pub vm_executor_teardown_timeout_ms : u64 ,
422457 pub blocking_worker_threads : usize ,
423458 pub max_blocking_jobs : usize ,
@@ -438,7 +473,7 @@ impl Default for RuntimeConfig {
438473 . unwrap_or ( 1 ) ;
439474 Self {
440475 worker_threads : available. clamp ( 1 , 4 ) ,
441- max_active_vm_executors : available . max ( 1 ) ,
476+ max_active_guest_executions : DEFAULT_MAX_ACTIVE_GUEST_EXECUTIONS ,
442477 vm_executor_teardown_timeout_ms : DEFAULT_VM_EXECUTOR_TEARDOWN_TIMEOUT_MS ,
443478 blocking_worker_threads : available. clamp ( 1 , 4 ) ,
444479 max_blocking_jobs : DEFAULT_MAX_BLOCKING_JOBS ,
@@ -455,12 +490,46 @@ impl Default for RuntimeConfig {
455490}
456491
457492impl RuntimeConfig {
493+ /// Apply operator overrides from the process environment.
494+ ///
495+ /// Call this from the process entrypoint, before [`SidecarRuntime::process`]
496+ /// fixes the topology. A present-but-unusable value is a hard, typed error
497+ /// naming the variable and its bounds: an operator who asked for a specific
498+ /// admission ceiling must never silently get a different one.
499+ pub fn apply_env_overrides ( & mut self ) -> Result < ( ) , RuntimeBuildError > {
500+ self . apply_env_overrides_from ( |key| std:: env:: var ( key) . ok ( ) )
501+ }
502+
503+ /// Testable core of [`apply_env_overrides`]. `read` resolves a variable
504+ /// name to its value, mirroring `std::env::var(..).ok()`.
505+ pub fn apply_env_overrides_from (
506+ & mut self ,
507+ read : impl Fn ( & str ) -> Option < String > ,
508+ ) -> Result < ( ) , RuntimeBuildError > {
509+ let Some ( raw) = read ( MAX_ACTIVE_GUEST_EXECUTIONS_ENV ) else {
510+ return Ok ( ( ) ) ;
511+ } ;
512+ let value = raw. trim ( ) ;
513+ let parsed: usize = value. parse ( ) . map_err ( |_| {
514+ RuntimeBuildError ( format ! (
515+ "ERR_AGENTOS_RUNTIME_CONFIG: {MAX_ACTIVE_GUEST_EXECUTIONS_ENV} must be an integer between 1 and {MAX_ACTIVE_GUEST_EXECUTIONS_CEILING}, got {value:?}"
516+ ) )
517+ } ) ?;
518+ if parsed == 0 || parsed > MAX_ACTIVE_GUEST_EXECUTIONS_CEILING {
519+ return Err ( RuntimeBuildError ( format ! (
520+ "ERR_AGENTOS_RUNTIME_CONFIG: {MAX_ACTIVE_GUEST_EXECUTIONS_ENV} must be between 1 and {MAX_ACTIVE_GUEST_EXECUTIONS_CEILING}, got {parsed}"
521+ ) ) ) ;
522+ }
523+ self . max_active_guest_executions = parsed;
524+ Ok ( ( ) )
525+ }
526+
458527 pub fn validate ( & self ) -> Result < ( ) , RuntimeBuildError > {
459528 for ( field, value) in [
460529 ( "runtime.workerThreads" , self . worker_threads ) ,
461530 (
462- "runtime.executor.maxActiveVms " ,
463- self . max_active_vm_executors ,
531+ "runtime.executor.maxActiveGuestExecutions " ,
532+ self . max_active_guest_executions ,
464533 ) ,
465534 (
466535 "runtime.blocking.workerThreads" ,
@@ -1114,7 +1183,7 @@ pub struct RuntimeContext {
11141183 fairness : FairWorkBroker ,
11151184 terminal_failure : Arc < Mutex < Option < TaskTerminalReport > > > ,
11161185 task_poll_watchdog : Duration ,
1117- max_active_vm_executors : usize ,
1186+ max_active_guest_executions : usize ,
11181187 vm_executor_teardown_timeout : Duration ,
11191188 blocking_job_timeout : Duration ,
11201189 admission_open : Arc < AtomicBool > ,
@@ -1145,8 +1214,8 @@ impl RuntimeContext {
11451214 & self . metrics
11461215 }
11471216
1148- pub fn max_active_vm_executors ( & self ) -> usize {
1149- self . max_active_vm_executors
1217+ pub fn max_active_guest_executions ( & self ) -> usize {
1218+ self . max_active_guest_executions
11501219 }
11511220
11521221 pub fn vm_executor_teardown_timeout ( & self ) -> Duration {
@@ -1257,7 +1326,7 @@ impl RuntimeContext {
12571326 fairness : self . fairness . clone ( ) ,
12581327 terminal_failure : Arc :: new ( Mutex :: new ( None ) ) ,
12591328 task_poll_watchdog : self . task_poll_watchdog ,
1260- max_active_vm_executors : self . max_active_vm_executors ,
1329+ max_active_guest_executions : self . max_active_guest_executions ,
12611330 vm_executor_teardown_timeout : self . vm_executor_teardown_timeout ,
12621331 blocking_job_timeout : self . blocking_job_timeout ,
12631332 admission_open,
@@ -1535,7 +1604,7 @@ impl SidecarRuntime {
15351604 fairness,
15361605 terminal_failure : Arc :: new ( Mutex :: new ( None ) ) ,
15371606 task_poll_watchdog : Duration :: from_millis ( config. task_poll_watchdog_ms ) ,
1538- max_active_vm_executors : config. max_active_vm_executors ,
1607+ max_active_guest_executions : config. max_active_guest_executions ,
15391608 vm_executor_teardown_timeout : Duration :: from_millis (
15401609 config. vm_executor_teardown_timeout_ms ,
15411610 ) ,
@@ -1598,6 +1667,78 @@ impl SidecarRuntime {
15981667mod tests {
15991668 use super :: * ;
16001669
1670+ fn env_override ( value : Option < & str > ) -> Result < RuntimeConfig , RuntimeBuildError > {
1671+ let mut config = RuntimeConfig :: default ( ) ;
1672+ let value = value. map ( str:: to_owned) ;
1673+ config. apply_env_overrides_from ( |key| {
1674+ ( key == MAX_ACTIVE_GUEST_EXECUTIONS_ENV )
1675+ . then ( || value. clone ( ) )
1676+ . flatten ( )
1677+ } ) ?;
1678+ Ok ( config)
1679+ }
1680+
1681+ #[ test]
1682+ fn default_guest_execution_ceiling_does_not_depend_on_host_cpu_count ( ) {
1683+ // A CPU-derived ceiling rejected ordinary workloads on small hosts and
1684+ // made admitted concurrency differ per machine. Guest executions are
1685+ // bounded by threads and memory, not by cores.
1686+ assert_eq ! (
1687+ RuntimeConfig :: default ( ) . max_active_guest_executions,
1688+ DEFAULT_MAX_ACTIVE_GUEST_EXECUTIONS
1689+ ) ;
1690+ assert ! (
1691+ DEFAULT_MAX_ACTIVE_GUEST_EXECUTIONS >= 2 ,
1692+ "a shell and the command it waits on already need two slots"
1693+ ) ;
1694+ }
1695+
1696+ #[ test]
1697+ fn absent_guest_execution_override_keeps_the_default ( ) {
1698+ let config = env_override ( None ) . expect ( "absent override is not an error" ) ;
1699+ assert_eq ! (
1700+ config. max_active_guest_executions,
1701+ DEFAULT_MAX_ACTIVE_GUEST_EXECUTIONS
1702+ ) ;
1703+ }
1704+
1705+ #[ test]
1706+ fn guest_execution_override_applies_and_stays_valid ( ) {
1707+ let config = env_override ( Some ( " 128 " ) ) . expect ( "surrounding whitespace is accepted" ) ;
1708+ assert_eq ! ( config. max_active_guest_executions, 128 ) ;
1709+ config. validate ( ) . expect ( "override must stay valid" ) ;
1710+ }
1711+
1712+ #[ test]
1713+ fn unusable_guest_execution_override_is_a_typed_error ( ) {
1714+ // An operator who asked for a specific ceiling must never silently get a
1715+ // different one: no clamping, no falling back to the default.
1716+ for value in [ "" , "many" , "0" , "-1" , "1.5" ] {
1717+ let error = env_override ( Some ( value) ) . expect_err ( "unusable override must be rejected" ) ;
1718+ assert ! (
1719+ error. to_string( ) . contains( MAX_ACTIVE_GUEST_EXECUTIONS_ENV ) ,
1720+ "error must name the variable: {error}"
1721+ ) ;
1722+ }
1723+
1724+ let above_ceiling = MAX_ACTIVE_GUEST_EXECUTIONS_CEILING + 1 ;
1725+ let error = env_override ( Some ( & above_ceiling. to_string ( ) ) )
1726+ . expect_err ( "a request above the hard ceiling must be rejected" ) ;
1727+ assert ! (
1728+ error
1729+ . to_string( )
1730+ . contains( & MAX_ACTIVE_GUEST_EXECUTIONS_CEILING . to_string( ) ) ,
1731+ "error must name the ceiling: {error}"
1732+ ) ;
1733+
1734+ let at_ceiling = env_override ( Some ( & MAX_ACTIVE_GUEST_EXECUTIONS_CEILING . to_string ( ) ) )
1735+ . expect ( "the ceiling itself is admissible" ) ;
1736+ assert_eq ! (
1737+ at_ceiling. max_active_guest_executions,
1738+ MAX_ACTIVE_GUEST_EXECUTIONS_CEILING
1739+ ) ;
1740+ }
1741+
16011742 #[ test]
16021743 fn process_runtime_bounds_every_resource_class_by_default ( ) {
16031744 let runtime = SidecarRuntime :: build ( RuntimeConfig :: default ( ) ) . expect ( "build runtime" ) ;
@@ -1650,12 +1791,14 @@ mod tests {
16501791 . contains( "runtime.tasks.maxTerminalReports" ) ) ;
16511792
16521793 let error = RuntimeConfig {
1653- max_active_vm_executors : 0 ,
1794+ max_active_guest_executions : 0 ,
16541795 ..RuntimeConfig :: default ( )
16551796 }
16561797 . validate ( )
1657- . expect_err ( "zero VM executor capacity must be rejected" ) ;
1658- assert ! ( error. to_string( ) . contains( "runtime.executor.maxActiveVms" ) ) ;
1798+ . expect_err ( "zero guest-execution capacity must be rejected" ) ;
1799+ assert ! ( error
1800+ . to_string( )
1801+ . contains( "runtime.executor.maxActiveGuestExecutions" ) ) ;
16591802
16601803 let error = RuntimeConfig {
16611804 vm_executor_teardown_timeout_ms : 0 ,
0 commit comments