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