Skip to main content

agentos_client/
sidecar.rs

1//! `AgentOsSidecar` (public transport handle) + placement/description + the process-global shared
2//! pool + internal lease accounting.
3//!
4//! Ported from `packages/core/src/agent-os.ts` (`AgentOsSidecar`). The shared-sidecar pool is a
5//! process-global map (default pool `"default"`).
6
7#[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
8use std::collections::HashMap;
9use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU8, Ordering};
10use std::sync::Arc;
11
12use once_cell::sync::OnceCell;
13use scc::HashMap as SccHashMap;
14use serde::Serialize;
15use uuid::Uuid;
16
17use agentos_sidecar_client::wire;
18
19use crate::agent_os::AgentOs;
20use crate::error::ClientError;
21use crate::transport::SidecarProcess;
22
23/// Maximum shared sidecar pool entries retained process-wide.
24const SHARED_SIDECAR_POOL_LIMIT: usize = 1024;
25
26/// Env var that overrides the Agent OS wrapper sidecar binary path.
27const AGENTOS_SIDECAR_BIN_ENV: &str = "AGENTOS_SIDECAR_BIN";
28
29static SHARED_SIDECAR_PACKAGE_CACHE: OnceCell<crate::software::ProcessPackageCacheOptions> =
30    OnceCell::new();
31
32/// Configure the package cache of child sidecars before opening any shared
33/// connection. The hosted worker uses this once at process startup.
34#[cfg(feature = "sidecar-internals")]
35pub fn configure_shared_sidecar_package_cache(
36    options: crate::software::ProcessPackageCacheOptions,
37) -> Result<(), ClientError> {
38    options.validate()?;
39    if let Some(existing) = SHARED_SIDECAR_PACKAGE_CACHE.get() {
40        if existing == &options {
41            return Ok(());
42        }
43        return Err(ClientError::PackageCacheConfiguration(
44            "shared sidecar package cache is already configured differently".into(),
45        ));
46    }
47    match SHARED_SIDECAR_PACKAGE_CACHE.set(options.clone()) {
48        Ok(()) => Ok(()),
49        Err(_) => configure_shared_sidecar_package_cache(options),
50    }
51}
52
53fn sidecar_spawn_args() -> Result<Vec<String>, ClientError> {
54    let Some(options) = SHARED_SIDECAR_PACKAGE_CACHE.get() else {
55        return Ok(Vec::new());
56    };
57    let mut args = vec!["sidecar".to_owned()];
58    if let Some(path) = &options.root {
59        let path = path.to_str().ok_or_else(|| {
60            ClientError::PackageCacheConfiguration(
61                "package cache directory is not valid UTF-8 for sidecar startup".into(),
62            )
63        })?;
64        args.extend(["--package-cache-dir".into(), path.into()]);
65    }
66    for (name, value) in [
67        ("--package-cache-min-free-bytes", options.min_free_bytes),
68        ("--package-cache-max-bytes", options.max_bytes),
69        ("--package-cache-max-entries", options.max_entries as u64),
70        (
71            "--package-cache-max-concurrent-acquisitions",
72            options.max_concurrent_acquisitions as u64,
73        ),
74        (
75            "--package-cache-max-pending-acquisitions",
76            options.max_pending_acquisitions as u64,
77        ),
78        (
79            "--package-cache-acquisition-timeout-ms",
80            options.acquisition_timeout_ms,
81        ),
82        (
83            "--package-cache-max-source-entries",
84            options.max_source_entries as u64,
85        ),
86        ("--package-cache-source-ttl-ms", options.source_ttl_ms),
87    ] {
88        args.extend([name.into(), value.to_string()]);
89    }
90    Ok(args)
91}
92
93/// The lazily-established shared sidecar process + authenticated connection. Multiple VMs in the same
94/// (shared) sidecar reuse this single process/connection, each opening its own session + VM on it.
95pub(crate) struct SharedConnection {
96    pub(crate) transport: Arc<SidecarProcess>,
97    pub(crate) connection_id: String,
98    #[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
99    package_session_id: Option<String>,
100    authenticated: bool,
101}
102
103/// Sidecar lifecycle state, encoded as a `u8` for `AtomicU8`.
104///
105/// Parity: TypeScript `describe()` returns a JSON-serializable description whose `state` is exactly
106/// `"ready" | "disposing" | "disposed"`. The `#[serde(rename_all = "lowercase")]` attribute and the
107/// matching [`SidecarState::as_str`] reproduce that wire string so [`AgentOsSidecarDescription`]
108/// serializes to the same JSON shape.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
110#[serde(rename_all = "lowercase")]
111pub enum SidecarState {
112    Ready,
113    Disposing,
114    Disposed,
115}
116
117impl SidecarState {
118    /// The TypeScript wire string for this state (`"ready" | "disposing" | "disposed"`).
119    pub const fn as_str(self) -> &'static str {
120        match self {
121            SidecarState::Ready => "ready",
122            SidecarState::Disposing => "disposing",
123            SidecarState::Disposed => "disposed",
124        }
125    }
126
127    pub(crate) const fn as_u8(self) -> u8 {
128        match self {
129            SidecarState::Ready => 0,
130            SidecarState::Disposing => 1,
131            SidecarState::Disposed => 2,
132        }
133    }
134
135    pub(crate) const fn from_u8(value: u8) -> Self {
136        match value {
137            0 => SidecarState::Ready,
138            1 => SidecarState::Disposing,
139            2 => SidecarState::Disposed,
140            // Any other bit pattern is unreachable; the field is only written via `as_u8`.
141            _ => SidecarState::Disposed,
142        }
143    }
144}
145
146/// Where a sidecar lives.
147///
148/// Parity: TypeScript `AgentOsSidecarPlacement` is `{ kind: "shared"; pool?: string }` or
149/// `{ kind: "explicit"; sidecarId: string }`. The serde `tag`/`rename` attributes reproduce that
150/// JSON shape, including omitting `pool` when it is `None` (matching the `...(pool ? { pool } : {})`
151/// spread in `getSharedAgentOsSidecarInternal`).
152#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
153#[serde(tag = "kind", rename_all = "lowercase")]
154pub enum AgentOsSidecarPlacement {
155    Shared {
156        #[serde(skip_serializing_if = "Option::is_none")]
157        pool: Option<String>,
158    },
159    Explicit {
160        #[serde(rename = "sidecarId")]
161        sidecar_id: String,
162    },
163}
164
165/// A sync, deep-clone snapshot of a sidecar's state.
166///
167/// Parity: serializes to the TypeScript `AgentOsSidecarDescription` JSON shape
168/// (`{ sidecarId, placement, state, activeVmCount }`).
169#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
170#[serde(rename_all = "camelCase")]
171pub struct AgentOsSidecarDescription {
172    pub sidecar_id: String,
173    pub placement: AgentOsSidecarPlacement,
174    pub state: SidecarState,
175    pub active_vm_count: u32,
176}
177
178/// Public transport handle for a (possibly shared) native sidecar process hosting VMs.
179pub struct AgentOsSidecar {
180    pub(crate) sidecar_id: String,
181    pub(crate) placement: AgentOsSidecarPlacement,
182    pub(crate) shared_pool: Option<String>,
183    pub(crate) state: AtomicU8,
184    pub(crate) active_vm_count: AtomicU32,
185    pub(crate) active_package_sessions: AtomicU32,
186    lifecycle: tokio::sync::Mutex<()>,
187    /// Absolute path to the `agentos-native-sidecar` binary, threaded from `AgentOsConfig` when present.
188    /// Otherwise `ensure_connection` resolves the Agent OS env fallback and passes an explicit path
189    /// to the generic transport.
190    pub(crate) sidecar_binary_path: Option<String>,
191    /// The shared sidecar process + authenticated connection, established on the first VM `create`
192    /// against this sidecar and reused by every subsequent VM in the same (shared) sidecar.
193    pub(crate) connection: tokio::sync::Mutex<Option<SharedConnection>>,
194}
195
196impl AgentOsSidecar {
197    /// Construct a sidecar handle.
198    pub(crate) fn new(
199        sidecar_id: impl Into<String>,
200        placement: AgentOsSidecarPlacement,
201        shared_pool: Option<String>,
202        sidecar_binary_path: Option<String>,
203    ) -> Self {
204        Self {
205            sidecar_id: sidecar_id.into(),
206            placement,
207            shared_pool,
208            state: AtomicU8::new(SidecarState::Ready.as_u8()),
209            active_vm_count: AtomicU32::new(0),
210            active_package_sessions: AtomicU32::new(0),
211            lifecycle: tokio::sync::Mutex::new(()),
212            sidecar_binary_path,
213            connection: tokio::sync::Mutex::new(None),
214        }
215    }
216
217    /// Get (or lazily establish) the shared sidecar process + authenticated connection. The first
218    /// caller spawns the native sidecar child and runs the `Authenticate` handshake; subsequent
219    /// callers reuse the same transport + connection id. This is what makes a shared sidecar host
220    /// multiple VMs in one process.
221    pub(crate) async fn ensure_connection(
222        &self,
223    ) -> Result<(Arc<SidecarProcess>, String, usize), ClientError> {
224        let mut guard = self.connection.lock().await;
225        if self.state.load(Ordering::SeqCst) != SidecarState::Ready.as_u8() {
226            return Err(ClientError::Sidecar(
227                "sidecar is disposing or disposed".into(),
228            ));
229        }
230        if let Some(existing) = guard.as_ref() {
231            if !existing.authenticated {
232                return Err(ClientError::Sidecar(
233                    "sidecar authentication did not complete; dispose this sidecar before retrying"
234                        .into(),
235                ));
236            }
237            let max_frame = existing.transport.max_frame_bytes();
238            return Ok((
239                existing.transport.clone(),
240                existing.connection_id.clone(),
241                max_frame,
242            ));
243        }
244
245        let transport = SidecarProcess::spawn_with_args(
246            Some(self.resolved_sidecar_binary_path()),
247            sidecar_spawn_args()?,
248        )
249        .await?;
250        // Retain ownership before the authentication await, so errors or
251        // cancellation cannot orphan a child that holds the cache directory.
252        *guard = Some(SharedConnection {
253            transport: transport.clone(),
254            connection_id: String::new(),
255            #[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
256            package_session_id: None,
257            authenticated: false,
258        });
259        let authed = match transport
260            .request_wire(
261                wire::OwnershipScope::ConnectionOwnership(wire::ConnectionOwnership {
262                    connection_id: "client-hint".to_string(),
263                }),
264                wire::RequestPayload::AuthenticateRequest(wire::AuthenticateRequest {
265                    client_name: "agentos-client".to_string(),
266                    auth_token: "agentos-client".to_string(),
267                    protocol_version: wire::PROTOCOL_VERSION,
268                    bridge_version: agentos_bridge::bridge_contract().version,
269                }),
270            )
271            .await?
272        {
273            wire::ResponsePayload::AuthenticatedResponse(authed) => authed,
274            wire::ResponsePayload::RejectedResponse(rejected) => {
275                return Err(ClientError::Kernel {
276                    code: rejected.code,
277                    message: rejected.message,
278                });
279            }
280            _ => {
281                return Err(ClientError::Sidecar(
282                    "unexpected authenticate response".to_string(),
283                ));
284            }
285        };
286        let max_frame = authed.max_frame_bytes as usize;
287        transport.set_max_frame_bytes(max_frame);
288
289        *guard = Some(SharedConnection {
290            transport: transport.clone(),
291            connection_id: authed.connection_id.clone(),
292            #[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
293            package_session_id: None,
294            authenticated: true,
295        });
296        Ok((transport, authed.connection_id, max_frame))
297    }
298
299    /// Kill the shared sidecar child process if a connection was established. Used when the last VM
300    /// on a shared sidecar shuts down, so the sidecar process does not leak (process-global pool
301    /// entries are never dropped, so `kill_on_drop` alone would not fire at process exit).
302    pub(crate) async fn kill_connection(&self) -> Result<(), ClientError> {
303        let mut connection = self.connection.lock().await;
304        if let Some(connection) = connection.as_ref() {
305            connection.transport.terminate_child().await?;
306        }
307        // Keep the transport reachable if termination fails or is cancelled.
308        connection.take();
309        Ok(())
310    }
311
312    pub(crate) async fn acquire_vm_lease(
313        self: &Arc<Self>,
314    ) -> Result<AgentOsSidecarVmLease, ClientError> {
315        self.acquire_lease(false).await
316    }
317
318    async fn acquire_lease(
319        self: &Arc<Self>,
320        package: bool,
321    ) -> Result<AgentOsSidecarVmLease, ClientError> {
322        let _lifecycle = self.lifecycle.lock().await;
323        if self.state.load(Ordering::SeqCst) != SidecarState::Ready.as_u8() {
324            return Err(ClientError::Sidecar(
325                "sidecar is disposing or disposed".into(),
326            ));
327        }
328        let count = if package {
329            &self.active_package_sessions
330        } else {
331            &self.active_vm_count
332        };
333        count
334            .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
335                count.checked_add(1)
336            })
337            .map_err(|_| {
338                ClientError::Sidecar("limit_exceeded: sidecar lease count overflow".into())
339            })?;
340        Ok(AgentOsSidecarVmLease {
341            sidecar: self.clone(),
342            package,
343            released: AtomicBool::new(false),
344        })
345    }
346
347    pub(crate) async fn dispose_if_unused(&self) -> Result<(), ClientError> {
348        let _lifecycle = self.lifecycle.lock().await;
349        if self.active_vm_count.load(Ordering::SeqCst) == 0
350            && self.active_package_sessions.load(Ordering::SeqCst) == 0
351        {
352            self.dispose_locked().await?;
353        }
354        Ok(())
355    }
356
357    /// Open a sidecar-owned package acquisition session before any VM exists.
358    #[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
359    pub async fn open_package_session(
360        self: &Arc<Self>,
361    ) -> Result<AgentOsPackageSession, ClientError> {
362        // Reserve before the first I/O await: last-VM shutdown must not kill
363        // the child while this session is still opening.
364        let lease = self.acquire_lease(true).await?;
365        let result = async {
366            let (transport, connection_id, _) = self.ensure_connection().await?;
367            let mut connection = self.connection.lock().await;
368            let current = connection.as_mut().ok_or_else(|| {
369                ClientError::Sidecar(
370                    "sidecar connection closed while opening package session".into(),
371                )
372            })?;
373            // The wire has no CloseSession operation. Reuse one acquisition
374            // session per connection so repeated open/close does not exhaust the
375            // bounded sidecar session table while sibling VMs remain alive.
376            let session_id = if let Some(session_id) = &current.package_session_id {
377                session_id.clone()
378            } else {
379                let response = transport
380                    .request_wire(
381                        wire::OwnershipScope::ConnectionOwnership(wire::ConnectionOwnership {
382                            connection_id: connection_id.clone(),
383                        }),
384                        wire::RequestPayload::OpenSessionRequest(wire::OpenSessionRequest {
385                            placement: crate::agent_os::sidecar_wire_placement(self),
386                            metadata: HashMap::new(),
387                        }),
388                    )
389                    .await?;
390                let wire::ResponsePayload::SessionOpenedResponse(opened) = response else {
391                    return Err(match response {
392                        wire::ResponsePayload::RejectedResponse(rejected) => {
393                            ClientError::from_rejection(rejected)
394                        }
395                        other => ClientError::Sidecar(format!(
396                            "unexpected package session response: {other:?}"
397                        )),
398                    });
399                };
400                current.package_session_id = Some(opened.session_id.clone());
401                opened.session_id
402            };
403            drop(connection);
404            Ok((transport, connection_id, session_id))
405        }
406        .await;
407        let (transport, connection_id, session_id) = match result {
408            Ok(opened) => opened,
409            Err(error) => {
410                lease.release();
411                if let Err(cleanup_error) = self.dispose_if_unused().await {
412                    tracing::error!(%cleanup_error, "failed to stop sidecar after package session open failed");
413                }
414                return Err(error);
415            }
416        };
417        Ok(AgentOsPackageSession {
418            sidecar: self.clone(),
419            transport,
420            connection_id,
421            session_id,
422            closed: AtomicBool::new(false),
423            lease,
424        })
425    }
426
427    fn resolved_sidecar_binary_path(&self) -> String {
428        self.sidecar_binary_path
429            .clone()
430            .or_else(|| std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok())
431            .unwrap_or_else(|| "agentos-native-sidecar".to_string())
432    }
433
434    /// Snapshot the sidecar's current state. SYNC.
435    ///
436    /// Parity: TypeScript `describe()` returns a deep clone of the internal description so callers
437    /// cannot mutate sidecar state through the returned value. The Rust struct derives `Clone`, so
438    /// constructing a fresh [`AgentOsSidecarDescription`] from the current atomics produces the same
439    /// snapshot semantics.
440    pub fn describe(&self) -> AgentOsSidecarDescription {
441        AgentOsSidecarDescription {
442            sidecar_id: self.sidecar_id.clone(),
443            placement: self.placement.clone(),
444            state: SidecarState::from_u8(self.state.load(Ordering::SeqCst)),
445            active_vm_count: self.active_vm_count.load(Ordering::SeqCst),
446        }
447    }
448
449    /// Dispose the sidecar. Idempotent; disposes active leases and aggregates errors.
450    ///
451    /// Parity with TypeScript `AgentOsSidecar.dispose()`:
452    /// 1. If already `disposed`, return immediately (idempotent).
453    /// 2. Transition to `disposing`.
454    /// 3. Dispose every active lease, collecting (not short-circuiting on) errors.
455    /// 4. Reset `active_vm_count` to 0 and transition to `disposed`.
456    /// 5. If this sidecar is the cached shared sidecar for its pool, remove it from the pool.
457    /// 6. If any lease disposal failed, return an aggregated error.
458    pub async fn dispose(&self) -> Result<(), ClientError> {
459        let _lifecycle = self.lifecycle.lock().await;
460        self.dispose_locked().await
461    }
462
463    async fn dispose_locked(&self) -> Result<(), ClientError> {
464        if SidecarState::from_u8(self.state.load(Ordering::SeqCst)) == SidecarState::Disposed {
465            return Ok(());
466        }
467
468        self.state
469            .store(SidecarState::Disposing.as_u8(), Ordering::SeqCst);
470
471        // Explicit sidecar disposal owns all its VMs. Reap the process before
472        // reporting disposal, even if a failed VM still retained a lease.
473        // On cancellation/error, remain Disposing and retain the connection
474        // so a later call can finish instead of reporting false success.
475        self.kill_connection().await?;
476        self.active_vm_count.store(0, Ordering::SeqCst);
477        self.active_package_sessions.store(0, Ordering::SeqCst);
478        self.state
479            .store(SidecarState::Disposed.as_u8(), Ordering::SeqCst);
480
481        if let Some(pool) = self.shared_pool.as_deref() {
482            // Only remove the cached entry if it still points at this exact sidecar instance.
483            let self_ptr = self as *const AgentOsSidecar;
484            let _ = shared_sidecars()
485                .remove_if(pool, |cached| std::ptr::eq(Arc::as_ptr(cached), self_ptr));
486        }
487
488        Ok(())
489    }
490}
491
492/// A process-scoped sidecar session for package acquisition without a VM.
493#[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
494pub struct AgentOsPackageSession {
495    sidecar: Arc<AgentOsSidecar>,
496    transport: Arc<SidecarProcess>,
497    connection_id: String,
498    session_id: String,
499    closed: AtomicBool,
500    lease: AgentOsSidecarVmLease,
501}
502
503#[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
504impl AgentOsPackageSession {
505    pub async fn cache_stats(
506        &self,
507    ) -> Result<crate::software::ProcessPackageCacheStats, ClientError> {
508        if self.closed.load(Ordering::SeqCst) {
509            return Err(ClientError::Sidecar("package session is closed".into()));
510        }
511        let response = self
512            .transport
513            .request_wire(
514                wire::OwnershipScope::SessionOwnership(wire::SessionOwnership {
515                    connection_id: self.connection_id.clone(),
516                    session_id: self.session_id.clone(),
517                }),
518                wire::RequestPayload::GetPackageCacheStatsRequest,
519            )
520            .await?;
521        match response {
522            wire::ResponsePayload::PackageCacheStatsResponse(stats) => {
523                let count = |value: u64| usize::try_from(value).unwrap_or(usize::MAX);
524                Ok(crate::software::ProcessPackageCacheStats {
525                    entries: count(stats.entries),
526                    source_entries: count(stats.source_entries),
527                    bytes: stats.bytes,
528                    pinned_entries: count(stats.pinned_entries),
529                    pending_acquisitions: count(stats.pending_acquisitions),
530                    hits: stats.hits,
531                    misses: stats.misses,
532                    coalesced_waiters: stats.coalesced_waiters,
533                    acquisitions: stats.acquisitions,
534                    evictions: stats.evictions,
535                    capacity_failures: stats.capacity_failures,
536                    cancelled_acquisitions: stats.cancelled_acquisitions,
537                })
538            }
539            wire::ResponsePayload::RejectedResponse(rejected) => {
540                Err(ClientError::from_rejection(rejected))
541            }
542            other => Err(ClientError::Sidecar(format!(
543                "unexpected package cache stats response: {other:?}"
544            ))),
545        }
546    }
547
548    pub async fn acquire(
549        &self,
550        source: crate::software::PackageSource,
551        advisory: bool,
552        timeout_ms: Option<u64>,
553        options: Option<&crate::software::PackageResolverOptions>,
554    ) -> Result<crate::software::InstalledSoftware, ClientError> {
555        if self.closed.load(Ordering::SeqCst) {
556            return Err(ClientError::Sidecar("package session is closed".into()));
557        }
558        let source = match source {
559            crate::software::PackageSource::Url {
560                url,
561                expected_digest,
562            } => wire::PackageAcquisitionSource::PackageUrlSource(wire::PackageUrlSource {
563                url,
564                expected_digest,
565            }),
566            crate::software::PackageSource::Path {
567                path,
568                expected_digest,
569            } => wire::PackageAcquisitionSource::PackagePathSource(wire::PackagePathSource {
570                path,
571                expected_digest,
572            }),
573        };
574        let response = self
575            .transport
576            .request_wire(
577                wire::OwnershipScope::SessionOwnership(wire::SessionOwnership {
578                    connection_id: self.connection_id.clone(),
579                    session_id: self.session_id.clone(),
580                }),
581                wire::RequestPayload::AcquirePackageRequest(wire::AcquirePackageRequest {
582                    source,
583                    advisory,
584                    timeout_ms,
585                    max_package_bytes: options.map(|options| options.max_package_bytes),
586                    download_timeout_ms: options.map(|options| options.download_timeout_ms),
587                    connect_timeout_ms: options.map(|options| options.connect_timeout_ms),
588                    max_redirects: options.map(|options| options.max_redirects as u32),
589                    allow_insecure_local_http: options
590                        .is_some_and(|options| options.allow_insecure_local_http),
591                }),
592            )
593            .await?;
594        match response {
595            wire::ResponsePayload::PackageAcquiredResponse(package) => {
596                Ok(crate::software::InstalledSoftware {
597                    package_id: package.package_id,
598                    digest: package.digest,
599                    size_bytes: package.size,
600                    package_name: package.package_name,
601                    version: package.version,
602                    commands: package.commands,
603                })
604            }
605            wire::ResponsePayload::RejectedResponse(rejected) => {
606                Err(ClientError::from_rejection(rejected))
607            }
608            other => Err(ClientError::Sidecar(format!(
609                "unexpected acquire_package response: {other:?}"
610            ))),
611        }
612    }
613
614    pub async fn close(&self) -> Result<(), ClientError> {
615        self.closed.store(true, Ordering::SeqCst);
616        self.lease.release();
617        // Retry unfinished disposal even when an earlier close was cancelled.
618        self.sidecar.dispose_if_unused().await
619    }
620
621    #[cfg(feature = "service-internals")]
622    pub(crate) async fn shutdown_worker(&self) -> Result<(), ClientError> {
623        self.closed.store(true, Ordering::SeqCst);
624        self.lease.release();
625        self.sidecar.dispose().await
626    }
627}
628
629/// A lease over a VM; released on `AgentOs` dispose.
630pub(crate) struct AgentOsSidecarVmLease {
631    pub(crate) sidecar: Arc<AgentOsSidecar>,
632    package: bool,
633    released: AtomicBool,
634}
635
636impl AgentOsSidecarVmLease {
637    /// An initialization error has no VM handle left to retry disposal. Keep
638    /// that ownership counted until the explicit worker owner stops the child.
639    pub(crate) fn retain_until_worker_shutdown(self) {
640        self.released.store(true, Ordering::SeqCst);
641    }
642
643    /// Release the lease.
644    ///
645    /// Parity with the TypeScript lease `dispose()`: it is idempotent, removes itself from the
646    /// owning sidecar's active-lease set, recomputes `activeVmCount`, and disposes the underlying
647    /// session transport client. Consuming `self` here gives the idempotence for free (the lease
648    /// cannot be disposed twice). The active-vm count is decremented (saturating at 0) to mirror
649    /// `state.description.activeVmCount = state.activeLeases.size`.
650    ///
651    pub(crate) async fn dispose(self) -> Result<(), ClientError> {
652        self.release();
653        Ok(())
654    }
655
656    fn release(&self) -> bool {
657        if self.released.swap(true, Ordering::SeqCst) {
658            return false;
659        }
660        let count = if self.package {
661            &self.sidecar.active_package_sessions
662        } else {
663            &self.sidecar.active_vm_count
664        };
665        count
666            .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
667                Some(count.saturating_sub(1))
668            })
669            .expect("saturating lease decrement");
670        true
671    }
672}
673
674impl Drop for AgentOsSidecarVmLease {
675    fn drop(&mut self) {
676        if self.release()
677            && self.sidecar.state.load(Ordering::SeqCst) == SidecarState::Ready.as_u8()
678        {
679            tracing::warn!(sidecar_id = %self.sidecar.sidecar_id, "sidecar lease dropped without explicit cleanup; ownership released, call sidecar.dispose() to confirm child teardown");
680        }
681    }
682}
683
684/// Process-global shared-sidecar pool, keyed by pool name (default `"default"`).
685static SHARED_SIDECARS: OnceCell<SccHashMap<String, Arc<AgentOsSidecar>>> = OnceCell::new();
686static SHARED_SIDECAR_POOL_LOCK: OnceCell<parking_lot::Mutex<()>> = OnceCell::new();
687
688/// Access (initializing on first use) the process-global shared-sidecar pool.
689pub(crate) fn shared_sidecars() -> &'static SccHashMap<String, Arc<AgentOsSidecar>> {
690    SHARED_SIDECARS.get_or_init(SccHashMap::new)
691}
692
693fn shared_sidecar_pool_lock() -> &'static parking_lot::Mutex<()> {
694    SHARED_SIDECAR_POOL_LOCK.get_or_init(parking_lot::Mutex::default)
695}
696
697fn shared_sidecar_pool_len(cache: &SccHashMap<String, Arc<AgentOsSidecar>>) -> usize {
698    let mut len = 0;
699    cache.scan(|_, _| {
700        len += 1;
701    });
702    len
703}
704
705fn prune_disposed_shared_sidecars(cache: &SccHashMap<String, Arc<AgentOsSidecar>>) {
706    let mut disposed_pools = Vec::new();
707    cache.scan(|pool, sidecar| {
708        if sidecar.describe().state == SidecarState::Disposed {
709            disposed_pools.push(pool.clone());
710        }
711    });
712    for pool in disposed_pools {
713        let _ = cache.remove_if(&pool, |sidecar| {
714            sidecar.describe().state == SidecarState::Disposed
715        });
716    }
717}
718
719#[cfg(test)]
720fn ensure_shared_sidecar_pool_capacity(
721    cache: &SccHashMap<String, Arc<AgentOsSidecar>>,
722) -> Result<(), ClientError> {
723    if shared_sidecar_pool_len(cache) >= SHARED_SIDECAR_POOL_LIMIT {
724        return Err(shared_sidecar_pool_limit_error());
725    }
726    Ok(())
727}
728
729fn shared_sidecar_pool_limit_error() -> ClientError {
730    ClientError::Sidecar(format!(
731        "shared sidecar pool limit exceeded: at most {SHARED_SIDECAR_POOL_LIMIT} pools can be cached"
732    ))
733}
734
735impl AgentOs {
736    /// Create an explicit sidecar handle. `sidecar_id` defaults to `agentos-native-sidecar-<uuid>`.
737    ///
738    /// Parity with TypeScript `createAgentOsSidecarInternal`: the explicit handle carries an
739    /// `Explicit` placement whose `sidecar_id` echoes the resolved id and has no shared pool.
740    pub async fn create_sidecar(
741        sidecar_id: Option<String>,
742    ) -> Result<Arc<AgentOsSidecar>, ClientError> {
743        let sidecar_id =
744            sidecar_id.unwrap_or_else(|| format!("agentos-native-sidecar-{}", Uuid::new_v4()));
745        let placement = AgentOsSidecarPlacement::Explicit {
746            sidecar_id: sidecar_id.clone(),
747        };
748        Ok(Arc::new(AgentOsSidecar::new(
749            sidecar_id, placement, None, None,
750        )))
751    }
752
753    /// Get (or create) a pooled shared sidecar. Pool defaults to `"default"`. Uses the process-global
754    /// cache.
755    ///
756    /// Parity with TypeScript `getSharedAgentOsSidecarInternal`: return the cached sidecar for the
757    /// pool when it exists and is not disposed; otherwise build a fresh handle
758    /// (`agentos-shared-sidecar:<pool>`, `Shared` placement) and cache it. Because the cache is a
759    /// process-global concurrent map rather than a synchronously-checked `Map`, the insert is done
760    /// atomically with `entry`/`insert` so two racing callers converge on a single live handle.
761    pub async fn get_shared_sidecar(
762        pool: Option<String>,
763        sidecar_binary_path: Option<String>,
764    ) -> Result<Arc<AgentOsSidecar>, ClientError> {
765        let pool = pool.unwrap_or_else(|| "default".to_string());
766        let cache = shared_sidecars();
767        let _guard = shared_sidecar_pool_lock().lock();
768
769        // Fast path: reuse a cached, non-disposed sidecar for this pool.
770        if let Some(existing) = cache.read(&pool, |_, sidecar| sidecar.clone()) {
771            if existing.describe().state != SidecarState::Disposed {
772                return Ok(existing);
773            }
774        }
775        prune_disposed_shared_sidecars(cache);
776
777        // Parity: TypeScript builds placement `{ kind: "shared", ...(pool ? { pool } : {}) }`, so an
778        // empty-string pool (a non-nullish value that survives `?? "default"`) is OMITTED from the
779        // placement. The `sharedPool` field used for cache cleanup still carries the raw pool value.
780        let placement_pool = if pool.is_empty() {
781            None
782        } else {
783            Some(pool.clone())
784        };
785        let sidecar = Arc::new(AgentOsSidecar::new(
786            format!("agentos-shared-sidecar:{pool}"),
787            AgentOsSidecarPlacement::Shared {
788                pool: placement_pool,
789            },
790            Some(pool.clone()),
791            sidecar_binary_path,
792        ));
793
794        // Insert atomically, replacing a stale (disposed) entry but yielding to a live one that a
795        // concurrent caller may have just installed.
796        let cache_len = shared_sidecar_pool_len(cache);
797        match cache.entry(pool) {
798            scc::hash_map::Entry::Occupied(mut occupied) => {
799                if occupied.get().describe().state == SidecarState::Disposed {
800                    *occupied.get_mut() = sidecar.clone();
801                    Ok(sidecar)
802                } else {
803                    Ok(occupied.get().clone())
804                }
805            }
806            scc::hash_map::Entry::Vacant(vacant) => {
807                if cache_len >= SHARED_SIDECAR_POOL_LIMIT {
808                    return Err(shared_sidecar_pool_limit_error());
809                }
810                vacant.insert_entry(sidecar.clone());
811                Ok(sidecar)
812            }
813        }
814    }
815}
816
817#[cfg(test)]
818mod tests {
819    use super::*;
820    use std::sync::Mutex;
821
822    static ENV_LOCK: Mutex<()> = Mutex::new(());
823
824    #[tokio::test]
825    async fn opening_leases_prevent_last_vm_from_disposing_the_child() {
826        let sidecar = shared("opening-leases", SidecarState::Ready);
827        let vm = sidecar.acquire_vm_lease().await.unwrap();
828        let package = sidecar.acquire_lease(true).await.unwrap();
829        vm.dispose().await.unwrap();
830        sidecar.dispose_if_unused().await.unwrap();
831        assert_eq!(sidecar.describe().state, SidecarState::Ready);
832        assert_eq!(sidecar.active_package_sessions.load(Ordering::SeqCst), 1);
833        package.release();
834        sidecar.dispose_if_unused().await.unwrap();
835        assert_eq!(sidecar.describe().state, SidecarState::Disposed);
836        assert!(sidecar.acquire_vm_lease().await.is_err());
837        assert!(sidecar.ensure_connection().await.is_err());
838        drop(package);
839        assert_eq!(sidecar.active_package_sessions.load(Ordering::SeqCst), 0);
840    }
841
842    #[tokio::test]
843    async fn unconfirmed_vm_cleanup_keeps_ownership_until_force_disposal() {
844        let sidecar = shared("unconfirmed-cleanup", SidecarState::Ready);
845        sidecar
846            .acquire_vm_lease()
847            .await
848            .unwrap()
849            .retain_until_worker_shutdown();
850        sidecar.dispose_if_unused().await.unwrap();
851        assert_eq!(sidecar.describe().active_vm_count, 1);
852        assert_eq!(sidecar.describe().state, SidecarState::Ready);
853        sidecar.dispose().await.unwrap();
854        assert_eq!(sidecar.describe().active_vm_count, 0);
855        assert_eq!(sidecar.describe().state, SidecarState::Disposed);
856    }
857
858    #[tokio::test]
859    async fn failed_package_session_open_releases_its_reserved_lease() {
860        let sidecar = Arc::new(AgentOsSidecar::new(
861            "missing-binary",
862            AgentOsSidecarPlacement::Explicit {
863                sidecar_id: "missing-binary".into(),
864            },
865            None,
866            Some("/agentos-missing-test-binary".into()),
867        ));
868        assert!(sidecar.open_package_session().await.is_err());
869        assert_eq!(sidecar.active_package_sessions.load(Ordering::SeqCst), 0);
870        assert_eq!(sidecar.describe().state, SidecarState::Disposed);
871    }
872
873    #[tokio::test]
874    async fn failed_vm_creation_releases_its_reserved_lease() {
875        let sidecar = Arc::new(AgentOsSidecar::new(
876            "missing-vm-binary",
877            AgentOsSidecarPlacement::Explicit {
878                sidecar_id: "missing-vm-binary".into(),
879            },
880            None,
881            Some("/agentos-missing-test-binary".into()),
882        ));
883        let result = AgentOs::create(crate::AgentOsConfig {
884            sidecar: Some(crate::AgentOsSidecarConfig::Explicit {
885                handle: sidecar.clone(),
886            }),
887            ..Default::default()
888        })
889        .await;
890        assert!(result.is_err());
891        assert_eq!(sidecar.describe().active_vm_count, 0);
892        assert_eq!(sidecar.describe().state, SidecarState::Disposed);
893    }
894
895    fn shared(pool: &str, state: SidecarState) -> Arc<AgentOsSidecar> {
896        let sidecar = Arc::new(AgentOsSidecar::new(
897            format!("agentos-shared-sidecar:{pool}"),
898            AgentOsSidecarPlacement::Shared {
899                pool: Some(pool.to_string()),
900            },
901            Some(pool.to_string()),
902            None,
903        ));
904        sidecar.state.store(state.as_u8(), Ordering::SeqCst);
905        sidecar
906    }
907
908    #[test]
909    fn sidecar_binary_path_prefers_explicit_wrapper_path() {
910        let _guard = ENV_LOCK.lock().expect("env lock");
911        let previous = std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok();
912        std::env::set_var(AGENTOS_SIDECAR_BIN_ENV, "/tmp/from-env");
913        let sidecar = AgentOsSidecar::new(
914            "explicit-test",
915            AgentOsSidecarPlacement::Explicit {
916                sidecar_id: "explicit-test".to_string(),
917            },
918            None,
919            Some("/tmp/from-config".to_string()),
920        );
921
922        assert_eq!(sidecar.resolved_sidecar_binary_path(), "/tmp/from-config");
923
924        restore_env(AGENTOS_SIDECAR_BIN_ENV, previous);
925    }
926
927    #[test]
928    fn sidecar_binary_path_uses_agent_os_env_fallback() {
929        let _guard = ENV_LOCK.lock().expect("env lock");
930        let previous = std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok();
931        std::env::set_var(AGENTOS_SIDECAR_BIN_ENV, "/tmp/agentos-native-sidecar");
932        let sidecar = shared("env-test", SidecarState::Ready);
933
934        assert_eq!(
935            sidecar.resolved_sidecar_binary_path(),
936            "/tmp/agentos-native-sidecar"
937        );
938
939        restore_env(AGENTOS_SIDECAR_BIN_ENV, previous);
940    }
941
942    #[test]
943    fn sidecar_binary_path_defaults_to_agent_os_wrapper() {
944        let _guard = ENV_LOCK.lock().expect("env lock");
945        let previous = std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok();
946        std::env::remove_var(AGENTOS_SIDECAR_BIN_ENV);
947        let sidecar = shared("default-test", SidecarState::Ready);
948
949        assert_eq!(
950            sidecar.resolved_sidecar_binary_path(),
951            "agentos-native-sidecar"
952        );
953
954        restore_env(AGENTOS_SIDECAR_BIN_ENV, previous);
955    }
956
957    fn restore_env(key: &str, value: Option<String>) {
958        match value {
959            Some(value) => std::env::set_var(key, value),
960            None => std::env::remove_var(key),
961        }
962    }
963
964    #[test]
965    fn prune_disposed_shared_sidecars_keeps_live_entries() {
966        let cache = SccHashMap::new();
967        let _ = cache.insert("live".to_string(), shared("live", SidecarState::Ready));
968        let _ = cache.insert(
969            "disposed".to_string(),
970            shared("disposed", SidecarState::Disposed),
971        );
972
973        prune_disposed_shared_sidecars(&cache);
974
975        assert_eq!(shared_sidecar_pool_len(&cache), 1);
976        assert!(cache.read("live", |_, _| ()).is_some());
977        assert!(cache.read("disposed", |_, _| ()).is_none());
978    }
979
980    #[test]
981    fn shared_sidecar_pool_capacity_rejects_full_live_cache() {
982        let cache = SccHashMap::new();
983        for index in 0..SHARED_SIDECAR_POOL_LIMIT {
984            let pool = format!("pool-{index}");
985            let _ = cache.insert(pool.clone(), shared(&pool, SidecarState::Ready));
986        }
987
988        let error =
989            ensure_shared_sidecar_pool_capacity(&cache).expect_err("full cache should reject");
990
991        assert!(
992            error
993                .to_string()
994                .contains("shared sidecar pool limit exceeded"),
995            "unexpected error: {error}"
996        );
997    }
998
999    #[test]
1000    fn shared_sidecar_pool_capacity_allows_after_pruning_disposed_entries() {
1001        let cache = SccHashMap::new();
1002        for index in 0..SHARED_SIDECAR_POOL_LIMIT {
1003            let pool = format!("pool-{index}");
1004            let state = if index == 0 {
1005                SidecarState::Disposed
1006            } else {
1007                SidecarState::Ready
1008            };
1009            let _ = cache.insert(pool.clone(), shared(&pool, state));
1010        }
1011
1012        prune_disposed_shared_sidecars(&cache);
1013
1014        ensure_shared_sidecar_pool_capacity(&cache).expect("pruned cache should admit one entry");
1015        assert_eq!(
1016            shared_sidecar_pool_len(&cache),
1017            SHARED_SIDECAR_POOL_LIMIT - 1
1018        );
1019    }
1020
1021    #[tokio::test]
1022    async fn get_shared_sidecar_inserts_vacant_pool_without_reentrant_scan() {
1023        let pool = format!("unit-{}", Uuid::new_v4());
1024        let sidecar = AgentOs::get_shared_sidecar(Some(pool.clone()), None)
1025            .await
1026            .expect("shared sidecar");
1027
1028        assert_eq!(sidecar.shared_pool.as_deref(), Some(pool.as_str()));
1029
1030        sidecar.dispose().await.expect("dispose shared sidecar");
1031    }
1032
1033    #[test]
1034    fn dispose_removes_only_same_shared_sidecar_instance() {
1035        let pool = format!("dispose-race-{}", Uuid::new_v4());
1036        let old = shared(&pool, SidecarState::Ready);
1037        let replacement = shared(&pool, SidecarState::Ready);
1038        let cache = shared_sidecars();
1039        let _guard = shared_sidecar_pool_lock().lock();
1040
1041        let _ = cache.insert(pool.clone(), replacement.clone());
1042        old.state
1043            .store(SidecarState::Disposing.as_u8(), Ordering::SeqCst);
1044        old.active_vm_count.store(0, Ordering::SeqCst);
1045        old.state
1046            .store(SidecarState::Disposed.as_u8(), Ordering::SeqCst);
1047        let old_ptr = Arc::as_ptr(&old);
1048        let _ = cache.remove_if(&pool, |cached| std::ptr::eq(Arc::as_ptr(cached), old_ptr));
1049
1050        let cached = cache
1051            .read(&pool, |_, cached| cached.clone())
1052            .expect("replacement should remain cached");
1053        assert!(Arc::ptr_eq(&cached, &replacement));
1054
1055        let replacement_ptr = Arc::as_ptr(&replacement);
1056        let _ = cache.remove_if(&pool, |cached| {
1057            std::ptr::eq(Arc::as_ptr(cached), replacement_ptr)
1058        });
1059    }
1060}