diff --git a/crates/native-sidecar/src/main.rs b/crates/native-sidecar/src/main.rs index 019ac89ada..33aa6a4fda 100644 --- a/crates/native-sidecar/src/main.rs +++ b/crates/native-sidecar/src/main.rs @@ -4,6 +4,33 @@ use nix::fcntl::{fcntl, FcntlArg}; const CONTROL_FD: i32 = 3; +fn parse_runtime_config( + mut args: impl Iterator, +) -> Result { + let mut config = agentos_runtime::RuntimeConfig::default(); + while let Some(argument) = args.next() { + let value = if argument == "--max-active-vms" { + args.next() + .ok_or_else(|| String::from("--max-active-vms requires a positive integer"))? + } else if let Some(value) = argument.strip_prefix("--max-active-vms=") { + value.to_owned() + } else { + return Err(format!("unknown agentOS sidecar argument: {argument}")); + }; + let maximum = value.parse::().map_err(|_| { + format!("--max-active-vms must be a positive integer, received {value:?}") + })?; + if maximum == 0 { + return Err(String::from( + "--max-active-vms must be greater than zero when configured", + )); + } + config.max_active_vm_executors = Some(maximum); + } + config.validate().map_err(|error| error.to_string())?; + Ok(config) +} + fn main() { // Default to WARN so near-limit / backpressure warnings actually surface // (they were swallowed at ERROR-only); operators can tune via AGENTOS_LOG @@ -25,13 +52,42 @@ fn main() { ); std::process::exit(1); } + let runtime_config = match parse_runtime_config(std::env::args().skip(1)) { + Ok(config) => config, + Err(error) => { + tracing::error!(%error, "invalid agentOS sidecar configuration"); + std::process::exit(1); + } + }; // SAFETY: the process launch contract reserves fd 3 for the inherited // response/control socket and transfers its sole ownership to the sidecar. // The fcntl probe above establishes that the descriptor is open before it // is adopted. let control_fd = unsafe { OwnedFd::from_raw_fd(CONTROL_FD) }; - if let Err(error) = agentos_native_sidecar::stdio::run(control_fd) { + if let Err(error) = + agentos_native_sidecar::stdio::run_with_runtime_config(control_fd, runtime_config) + { tracing::error!(?error, "agentos-native-sidecar startup failed"); std::process::exit(1); } } + +#[cfg(test)] +mod tests { + use super::parse_runtime_config; + + #[test] + fn runtime_executor_limit_is_uncapped_by_default_and_configurable() { + let default = parse_runtime_config(std::iter::empty()).expect("parse default config"); + assert_eq!(default.max_active_vm_executors, None); + + let configured = + parse_runtime_config([String::from("--max-active-vms"), String::from("7")].into_iter()) + .expect("parse configured executor limit"); + assert_eq!(configured.max_active_vm_executors, Some(7)); + + let error = parse_runtime_config([String::from("--max-active-vms=0")].into_iter()) + .expect_err("zero executor limit must fail"); + assert!(error.contains("greater than zero")); + } +} diff --git a/crates/native-sidecar/src/stdio.rs b/crates/native-sidecar/src/stdio.rs index e60fe7b354..49b5a4f3ef 100644 --- a/crates/native-sidecar/src/stdio.rs +++ b/crates/native-sidecar/src/stdio.rs @@ -1386,6 +1386,13 @@ pub fn run(control_fd: OwnedFd) -> Result<(), Box> { run_with_extensions(Vec::new(), control_fd) } +pub fn run_with_runtime_config( + control_fd: OwnedFd, + runtime: agentos_runtime::RuntimeConfig, +) -> Result<(), Box> { + run_with_optional_control_and_runtime(Vec::new(), Some(control_fd), Some(runtime)) +} + pub fn run_combined() -> Result<(), Box> { run_combined_with_extensions(Vec::new()) } @@ -1407,10 +1414,21 @@ fn run_with_optional_control( extensions: Vec>, control_fd: Option, ) -> Result<(), Box> { - let config = NativeSidecarConfig { + run_with_optional_control_and_runtime(extensions, control_fd, None) +} + +fn run_with_optional_control_and_runtime( + extensions: Vec>, + control_fd: Option, + runtime: Option, +) -> Result<(), Box> { + let mut config = NativeSidecarConfig { compile_cache_root: Some(default_compile_cache_root()), ..NativeSidecarConfig::default() }; + if let Some(runtime) = runtime { + config.runtime = runtime; + } let runtime = agentos_runtime::SidecarRuntime::process(&config.runtime)?; let runtime_context = runtime.context(); // Initialize the embedded V8 runtime + platform now, on the long-lived main diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index 01b6d81b2b..2489253797 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -459,7 +459,10 @@ impl RuntimeResourceConfig { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RuntimeConfig { pub worker_threads: usize, - pub max_active_vm_executors: usize, + /// Optional process-wide ceiling for concurrently active V8 executors. + /// `None` leaves executor admission uncapped; CPU availability only sizes + /// the trusted worker pools. + pub max_active_vm_executors: Option, pub vm_executor_teardown_timeout_ms: u64, pub blocking_worker_threads: usize, pub max_blocking_jobs: usize, @@ -480,7 +483,7 @@ impl Default for RuntimeConfig { .unwrap_or(1); Self { worker_threads: available.clamp(1, 4), - max_active_vm_executors: available.max(1), + max_active_vm_executors: None, vm_executor_teardown_timeout_ms: DEFAULT_VM_EXECUTOR_TEARDOWN_TIMEOUT_MS, blocking_worker_threads: available.clamp(1, 4), max_blocking_jobs: DEFAULT_MAX_BLOCKING_JOBS, @@ -500,10 +503,6 @@ impl RuntimeConfig { pub fn validate(&self) -> Result<(), RuntimeBuildError> { for (field, value) in [ ("runtime.workerThreads", self.worker_threads), - ( - "runtime.executor.maxActiveVms", - self.max_active_vm_executors, - ), ( "runtime.blocking.workerThreads", self.blocking_worker_threads, @@ -711,6 +710,11 @@ impl RuntimeConfig { ))); } } + if self.max_active_vm_executors == Some(0) { + return Err(RuntimeBuildError(String::from( + "ERR_AGENTOS_RUNTIME_CONFIG: runtime.executor.maxActiveVms must be greater than zero when configured", + ))); + } if self.task_poll_watchdog_ms == 0 { return Err(RuntimeBuildError(String::from( "ERR_AGENTOS_RUNTIME_CONFIG: runtime.watchdog.taskPollMs must be greater than zero", @@ -1254,7 +1258,7 @@ pub struct RuntimeContext { fairness: FairWorkBroker, terminal_failure: Arc>>, task_poll_watchdog: Duration, - max_active_vm_executors: usize, + max_active_vm_executors: Option, vm_executor_teardown_timeout: Duration, blocking_job_timeout: Duration, admission_open: Arc, @@ -1285,7 +1289,7 @@ impl RuntimeContext { &self.metrics } - pub fn max_active_vm_executors(&self) -> usize { + pub fn max_active_vm_executors(&self) -> Option { self.max_active_vm_executors } @@ -1790,7 +1794,7 @@ mod tests { .contains("runtime.tasks.maxTerminalReports")); let error = RuntimeConfig { - max_active_vm_executors: 0, + max_active_vm_executors: Some(0), ..RuntimeConfig::default() } .validate() diff --git a/crates/v8-runtime/src/embedded_runtime.rs b/crates/v8-runtime/src/embedded_runtime.rs index aa83a3de23..eb3f934b5b 100644 --- a/crates/v8-runtime/src/embedded_runtime.rs +++ b/crates/v8-runtime/src/embedded_runtime.rs @@ -66,7 +66,7 @@ impl EmbeddedV8Runtime { let configured_max_concurrency = runtime.max_active_vm_executors(); let executor_teardown_timeout = runtime.vm_executor_teardown_timeout(); let session_mgr = Arc::new(Mutex::new(SessionManager::new( - max_concurrency.unwrap_or(configured_max_concurrency), + max_concurrency.or(configured_max_concurrency), crate::session::RuntimeEventSender::closed(), call_id_router, Arc::clone(&snapshot_cache), @@ -682,7 +682,7 @@ pub fn spawn_embedded_runtime_ipc( let shutdown_stream = host_stream.try_clone()?; let alive = Arc::new(AtomicBool::new(true)); let alive_for_thread = Arc::clone(&alive); - let max_concurrency = max_concurrency.unwrap_or_else(|| runtime.max_active_vm_executors()); + let max_concurrency = max_concurrency.or_else(|| runtime.max_active_vm_executors()); // AGENTOS_THREAD_SITE: embedded-v8-dispatch let join_handle = thread::Builder::new() @@ -706,7 +706,7 @@ pub fn spawn_embedded_runtime_ipc( fn run_embedded_runtime( stream: UnixStream, - max_concurrency: usize, + max_concurrency: Option, runtime: agentos_runtime::RuntimeContext, ) { // Keep bridge-only, agent-SDK, and wasm-runner userland variants warm @@ -1175,7 +1175,7 @@ mod tests { .lock() .expect("embedded runtime codec test lock poisoned"); let mut config = agentos_runtime::RuntimeConfig { - max_active_vm_executors: 2, + max_active_vm_executors: Some(2), vm_executor_teardown_timeout_ms: 31, ..agentos_runtime::RuntimeConfig::default() }; @@ -1192,7 +1192,7 @@ mod tests { .lock() .expect("session manager") .max_concurrency(), - 2 + Some(2) ); assert_eq!(runtime.executor_teardown_timeout, Duration::from_millis(31)); let (_receiver, registration) = runtime @@ -1357,7 +1357,7 @@ mod tests { let call_id_router: CallIdRouter = Arc::new(BridgeCallRegistry::with_default_limit()); let runtime = test_runtime_context(); let session_mgr = Arc::new(Mutex::new(SessionManager::new( - 1, + Some(1), event_tx, Arc::clone(&call_id_router), Arc::clone(&snapshot_cache), @@ -1808,7 +1808,7 @@ mod tests { fn test_session_manager() -> Arc> { let (event_tx, _event_rx) = crossbeam_channel::bounded::(1); Arc::new(Mutex::new(SessionManager::new( - 1, + Some(1), event_tx, Arc::new(BridgeCallRegistry::with_default_limit()), Arc::new(SnapshotCache::new(1)), diff --git a/crates/v8-runtime/src/session.rs b/crates/v8-runtime/src/session.rs index c5139ba709..bba89d9b45 100644 --- a/crates/v8-runtime/src/session.rs +++ b/crates/v8-runtime/src/session.rs @@ -1058,14 +1058,15 @@ struct SessionSlotPermit { impl SessionSlotPermit { fn try_acquire( control: &SlotControl, - maximum: usize, + maximum: Option, metrics: RuntimeMetrics, ) -> Result { let (lock, _) = &**control; let mut active = lock .lock() .map_err(|_| String::from("ERR_AGENTOS_VM_EXECUTOR_POISONED: slot lock poisoned"))?; - if *active >= maximum { + if maximum.is_some_and(|maximum| *active >= maximum) { + let maximum = maximum.expect("checked as present"); return Err(format!( "ERR_AGENTOS_VM_EXECUTOR_LIMIT: active V8 executors reached limit of {maximum}; raise runtime.executor.maxActiveVms" )); @@ -1222,7 +1223,7 @@ pub struct SessionManager { /// thread itself retains the concurrency permit, so a successor cannot /// consume capacity that is still running untrusted code. quarantined: Vec, - max_concurrency: usize, + max_concurrency: Option, slot_control: SlotControl, /// Typed runtime event sender shared across session threads. event_tx: RuntimeEventSender, @@ -1251,7 +1252,7 @@ struct QuarantinedSession { impl SessionManager { pub fn new( - max_concurrency: usize, + max_concurrency: Option, event_tx: impl Into, call_id_router: CallIdRouter, snapshot_cache: Arc, @@ -1273,7 +1274,7 @@ impl SessionManager { } #[cfg(test)] - pub(crate) fn max_concurrency(&self) -> usize { + pub(crate) fn max_concurrency(&self) -> Option { self.max_concurrency } @@ -3943,7 +3944,7 @@ mod tests { agentos_runtime::SidecarRuntime::process(&agentos_runtime::RuntimeConfig::default()) .expect("create test process runtime") .context(); - let manager = SessionManager::new(max, tx, router, snap_cache, runtime); + let manager = SessionManager::new(Some(max), tx, router, snap_cache, runtime); (manager, _rx) } @@ -3959,9 +3960,9 @@ mod tests { let control: SlotControl = Arc::new((Mutex::new(0), Condvar::new())); let metrics = RuntimeMetrics::new(); - let first = SessionSlotPermit::try_acquire(&control, 2, metrics.clone()) + let first = SessionSlotPermit::try_acquire(&control, Some(2), metrics.clone()) .expect("acquire first VM executor"); - let second = SessionSlotPermit::try_acquire(&control, 2, metrics.clone()) + let second = SessionSlotPermit::try_acquire(&control, Some(2), metrics.clone()) .expect("acquire second VM executor"); let active = metrics.snapshot().executors[ExecutorMetricClass::Vm.index()].active; assert_eq!(active.current, 2); @@ -4005,7 +4006,7 @@ mod tests { return; } let mut config = agentos_runtime::RuntimeConfig { - max_active_vm_executors: 3, + max_active_vm_executors: Some(3), vm_executor_teardown_timeout_ms: 23, ..agentos_runtime::RuntimeConfig::default() }; @@ -4023,7 +4024,7 @@ mod tests { runtime, ); - assert_eq!(manager.max_concurrency, 3); + assert_eq!(manager.max_concurrency, Some(3)); assert_eq!(manager.executor_teardown_timeout, Duration::from_millis(23)); manager .create_session("configured-bounds".into(), None, None, None) diff --git a/crates/v8-runtime/tests/embedded_runtime_session.rs b/crates/v8-runtime/tests/embedded_runtime_session.rs index 47552d90ee..cb3d32a728 100644 --- a/crates/v8-runtime/tests/embedded_runtime_session.rs +++ b/crates/v8-runtime/tests/embedded_runtime_session.rs @@ -35,6 +35,10 @@ fn embedded_runtime(max_concurrency: usize) -> io::Result { EmbeddedV8Runtime::new(Some(max_concurrency), process_runtime_context()?) } +fn default_embedded_runtime() -> io::Result { + EmbeddedV8Runtime::new(None, process_runtime_context()?) +} + fn vm_runtime_context(session_id: &str) -> io::Result { use agentos_runtime::accounting::{ResourceClass, ResourceLedger, ResourceLimit}; @@ -539,6 +543,23 @@ fn assert_overload_rejects_before_thread_and_recovers_after_release() -> io::Res Ok(()) } +fn assert_default_executor_admission_is_uncapped() -> io::Result<()> { + let runtime = Arc::new(default_embedded_runtime()?); + let first = next_session_id(); + let second = next_session_id(); + let _first_receiver = register_and_create_session(&runtime, &first)?; + let _second_receiver = register_and_create_session(&runtime, &second)?; + assert_eq!(runtime.active_slot_count(), 2); + + for session_id in [&first, &second] { + runtime.dispatch(RuntimeCommand::DestroySession { + session_id: session_id.clone(), + })?; + runtime.unregister_session(session_id); + } + Ok(()) +} + fn assert_shared_runtime_handles_share_concurrency_quota() -> io::Result<()> { let runtime = Arc::new(embedded_runtime(3)?); let clients = (0..4) @@ -1024,6 +1045,7 @@ fn embedded_runtime_session_consolidated_behaviors() -> io::Result<()> { assert_snapshot_rebuild_on_bridge_change()?; assert_execute_rejects_oversized_bridge_code()?; assert_direct_zero_cpu_time_limit_disables_timeout()?; + assert_default_executor_admission_is_uncapped()?; assert_overload_rejects_before_thread_and_recovers_after_release()?; assert_shared_runtime_handles_share_concurrency_quota()?; assert_sync_bridge_response_bypasses_stream_event_flood()?; diff --git a/docs/content/docs/architecture/javascript-executor.mdx b/docs/content/docs/architecture/javascript-executor.mdx index 53d36df94a..42232d9509 100644 --- a/docs/content/docs/architecture/javascript-executor.mdx +++ b/docs/content/docs/architecture/javascript-executor.mdx @@ -39,8 +39,9 @@ Guest V8 execution is deliberately different: the only non-V8 platform thread that enters that isolate. - Synchronous guest JavaScript or a synchronous bridge wait can block that executor, but cannot occupy a Tokio worker or another VM's executor. -- The number of active and warm executor threads is bounded separately from - socket and task counts. +- Operators can cap active executor threads separately from socket and task + counts with `runtime.executor.maxActiveVms`. Executor admission is uncapped + by default and is not derived from the sidecar's reported CPU count. There is therefore no "Tokio task running a Node.js process." Trusted I/O runs as Tokio tasks; untrusted JavaScript runs on a V8 executor thread. diff --git a/docs/content/docs/resource-limits.mdx b/docs/content/docs/resource-limits.mdx index 6f65dcd452..c7f8ec2a56 100644 --- a/docs/content/docs/resource-limits.mdx +++ b/docs/content/docs/resource-limits.mdx @@ -15,6 +15,11 @@ Every agentOS VM runs with **per-VM resource and runtime caps**. These caps cont Set caps on the `limits` object in the `agentOS` config. Limits are grouped by subsystem (`resources`, `process`, `jsRuntime`, `python`, `wasm`, and more). Omitted limits keep their secure default. +V8 executor admission is process-wide rather than per-VM. It is uncapped by +default and does not derive a ceiling from the sidecar's reported CPU count. +Operators can set `runtime.executor.maxActiveVms` when creating a sidecar to +enforce an explicit concurrent-executor ceiling. + ## Available caps diff --git a/examples/embedded/limits.ts b/examples/embedded/limits.ts index 14c19c3f3a..da2fe4c5b5 100644 --- a/examples/embedded/limits.ts +++ b/examples/embedded/limits.ts @@ -1,8 +1,13 @@ import { AgentOs } from "@rivet-dev/agentos-core"; +const sidecar = await AgentOs.createSidecar({ + runtime: { executor: { maxActiveVms: 8 } }, +}); + // The same `limits` object the actor takes. `onLimitWarning` is an embedded // create option rather than a broadcast event, so it fires only in this process. const vm = await AgentOs.create({ + sidecar: { kind: "explicit", handle: sidecar }, limits: { resources: { maxProcesses: 64, maxFilesystemBytes: 256 * 1024 * 1024 }, jsRuntime: { v8HeapLimitMb: 128, cpuTimeLimitMs: 30_000 }, @@ -12,3 +17,4 @@ const vm = await AgentOs.create({ }, }); await vm.dispose(); +await sidecar.dispose(); diff --git a/packages/core/src/agent-os.ts b/packages/core/src/agent-os.ts index 2c1a340d3e..6e786ac748 100644 --- a/packages/core/src/agent-os.ts +++ b/packages/core/src/agent-os.ts @@ -479,14 +479,28 @@ import { export interface AgentOsSharedSidecarOptions { pool?: string; + runtime?: AgentOsSidecarRuntimeConfig; } export interface AgentOsCreateSidecarOptions { sidecarId?: string; + runtime?: AgentOsSidecarRuntimeConfig; +} + +/** Process-wide runtime settings applied when the native sidecar starts. */ +export interface AgentOsSidecarRuntimeConfig { + executor?: { + /** Optional ceiling for concurrent V8 executors. Omit to leave admission uncapped. */ + maxActiveVms?: number; + }; } export type AgentOsSidecarConfig = - | { kind: "shared"; pool?: string } + | { + kind: "shared"; + pool?: string; + runtime?: AgentOsSidecarRuntimeConfig; + } | { kind: "explicit"; handle: AgentOsSidecar }; export interface AgentOsSidecarDescription { @@ -6862,7 +6876,9 @@ function resolveAgentOsSidecar( ): AgentOsSidecar { if (!config || config.kind === "shared") { return getSharedAgentOsSidecarInternal( - config?.kind === "shared" ? { pool: config.pool } : undefined, + config?.kind === "shared" + ? { pool: config.pool, runtime: config.runtime } + : undefined, ); } @@ -6894,6 +6910,7 @@ interface SharedSidecarNativeProcess { interface AgentOsSidecarState { description: AgentOsSidecarDescription; + runtime: AgentOsSidecarRuntimeConfig; activeLeases: Set; sharedPool?: string; /** @@ -7047,7 +7064,7 @@ function ensureSharedSidecarNativeProcess( const client = SidecarProcess.spawn({ cwd: REPO_ROOT, command: ensureNativeSidecarBinary(), - args: [], + args: sidecarRuntimeArgs(state.runtime), }); // Track the child immediately — BEFORE the handshake await — so a // failed `authenticateAndOpenSession()` can still reap it (otherwise @@ -7117,6 +7134,7 @@ export class AgentOsSidecar { sidecarId: string, placement: AgentOsSidecarPlacement, sharedPool?: string, + runtime?: AgentOsSidecarRuntimeConfig, ) { sidecarStates.set(this, { description: { @@ -7127,6 +7145,7 @@ export class AgentOsSidecar { }, activeLeases: new Set(), sharedPool, + runtime: normalizeSidecarRuntimeConfig(runtime), }); } @@ -7168,10 +7187,15 @@ function createAgentOsSidecarInternal( options: AgentOsCreateSidecarOptions = {}, ): AgentOsSidecar { const sidecarId = options.sidecarId ?? `agentos-sidecar-${randomUUID()}`; - return new AgentOsSidecar(sidecarId, { - kind: "explicit", + return new AgentOsSidecar( sidecarId, - }); + { + kind: "explicit", + sidecarId, + }, + undefined, + options.runtime, + ); } /** @@ -7205,6 +7229,15 @@ function getSharedAgentOsSidecarInternal( const pool = options.pool ?? "default"; const existing = sharedSidecars.get(pool); if (existing && existing.describe().state !== "disposed") { + if (options.runtime !== undefined) { + const requested = normalizeSidecarRuntimeConfig(options.runtime); + const configured = getSidecarState(existing).runtime; + if (!sidecarRuntimeConfigsEqual(requested, configured)) { + throw new Error( + `Shared sidecar pool ${JSON.stringify(pool)} already exists with different runtime settings`, + ); + } + } return existing; } @@ -7212,11 +7245,39 @@ function getSharedAgentOsSidecarInternal( `agentos-shared-sidecar:${pool}`, { kind: "shared", ...(pool ? { pool } : {}) }, pool, + options.runtime, ); sharedSidecars.set(pool, sidecar); return sidecar; } +function normalizeSidecarRuntimeConfig( + runtime: AgentOsSidecarRuntimeConfig | undefined, +): AgentOsSidecarRuntimeConfig { + const maxActiveVms = runtime?.executor?.maxActiveVms; + if (maxActiveVms === undefined) return {}; + if (!Number.isSafeInteger(maxActiveVms) || maxActiveVms <= 0) { + throw new Error( + "runtime.executor.maxActiveVms must be a positive safe integer", + ); + } + return { executor: { maxActiveVms } }; +} + +function sidecarRuntimeConfigsEqual( + left: AgentOsSidecarRuntimeConfig, + right: AgentOsSidecarRuntimeConfig, +): boolean { + return left.executor?.maxActiveVms === right.executor?.maxActiveVms; +} + +function sidecarRuntimeArgs(runtime: AgentOsSidecarRuntimeConfig): string[] { + const maxActiveVms = runtime.executor?.maxActiveVms; + return maxActiveVms === undefined + ? [] + : ["--max-active-vms", String(maxActiveVms)]; +} + async function leaseAgentOsSidecarVm( sidecar: AgentOsSidecar, options: CreateInProcessSidecarTransportOptions, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ede6c13beb..183da6a617 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -29,6 +29,7 @@ export { sharedSidecarConfigSchema, sidecarConfigSchema, bindingsSchema, + sidecarRuntimeConfigSchema, } from "./options-schema.js"; export { createSnapshotExport } from "./layers.js"; export { defineSoftware } from "./packages.js"; diff --git a/packages/core/src/options-schema.ts b/packages/core/src/options-schema.ts index 13353e5de8..47bd494b72 100644 --- a/packages/core/src/options-schema.ts +++ b/packages/core/src/options-schema.ts @@ -323,10 +323,22 @@ export const mountConfigSchema = z.union([ overlayMountConfigSchema, ]); +export const sidecarRuntimeConfigSchema = z + .object({ + executor: z + .object({ + maxActiveVms: positiveInteger.max(Number.MAX_SAFE_INTEGER).optional(), + }) + .strict() + .optional(), + }) + .strict(); + export const sharedSidecarConfigSchema = z .object({ kind: z.literal("shared"), pool: z.string().optional(), + runtime: sidecarRuntimeConfigSchema.optional(), }) .strict(); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 751de23b09..007ab2c54f 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -7,6 +7,7 @@ export type { AgentOsSharedSidecarOptions, AgentOsSidecarConfig, AgentOsSidecarDescription, + AgentOsSidecarRuntimeConfig, AgentRegistryEntry, AgentRestartOutcome, AgentStderrEvent, diff --git a/packages/core/tests/public-api-exports.test.ts b/packages/core/tests/public-api-exports.test.ts index 2c1922c6ab..78a76e6080 100644 --- a/packages/core/tests/public-api-exports.test.ts +++ b/packages/core/tests/public-api-exports.test.ts @@ -4,6 +4,7 @@ import { AgentOs, type AgentOsLimits, AgentOsSidecar, + type AgentOsSidecarRuntimeConfig, agentOsLimitsSchema, agentOsOptionsSchema, binding, @@ -45,6 +46,7 @@ import { type SessionStreamEntry, type SpawnOptions, type StdioChannel, + sidecarRuntimeConfigSchema, TimerScheduleDriver, type TimingMitigation, validateBindings, @@ -113,8 +115,13 @@ describe("root public API exports", () => { expect(OPT_AGENTOS_BIN).toBe("/opt/agentos/bin"); }); + test("re-exports the sidecar runtime configuration schema", () => { + expect(sidecarRuntimeConfigSchema).toBeTypeOf("object"); + }); + test("re-exports current public SDK types from the root entrypoint", () => { void (null as AgentOsLimits | null); + void (null as AgentOsSidecarRuntimeConfig | null); void (null as ContextDescriptor | null); void (null as ExecOptions | null); void (null as HostDirMountPluginConfig | null); diff --git a/packages/core/tests/sidecar-placement.test.ts b/packages/core/tests/sidecar-placement.test.ts index 4c97ddd46c..cdd9781438 100644 --- a/packages/core/tests/sidecar-placement.test.ts +++ b/packages/core/tests/sidecar-placement.test.ts @@ -81,4 +81,35 @@ describe("AgentOs sidecar placement", () => { await sidecar.dispose(); } }); + + test("validates and preserves process-wide executor settings per shared pool", async () => { + const sidecar = await AgentOs.getSharedSidecar({ + pool: "configured-runtime", + runtime: { executor: { maxActiveVms: 7 } }, + }); + try { + expect( + await AgentOs.getSharedSidecar({ + pool: "configured-runtime", + runtime: { executor: { maxActiveVms: 7 } }, + }), + ).toBe(sidecar); + await expect( + AgentOs.getSharedSidecar({ + pool: "configured-runtime", + runtime: { executor: { maxActiveVms: 8 } }, + }), + ).rejects.toThrow("already exists with different runtime settings"); + } finally { + await sidecar.dispose(); + } + + await expect( + AgentOs.createSidecar({ + runtime: { executor: { maxActiveVms: 0 } }, + }), + ).rejects.toThrow( + "runtime.executor.maxActiveVms must be a positive safe integer", + ); + }); });