Skip to main content

hyperactor_mesh/host_mesh/
host_agent.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! The mesh agent actor that manages a host.
10
11// EnumAsInner generates code that triggers a false positive
12// unused_assignments lint on struct variant fields. #[allow] on the
13// enum itself doesn't propagate into derive-macro-generated code, so
14// the suppression must be at module scope.
15#![allow(unused_assignments)]
16
17use std::collections::HashMap;
18use std::collections::HashSet;
19use std::collections::hash_map::DefaultHasher;
20use std::fmt;
21use std::hash::Hash;
22use std::hash::Hasher;
23use std::pin::Pin;
24use std::sync::OnceLock;
25
26use async_trait::async_trait;
27use enum_as_inner::EnumAsInner;
28use hyperactor::Actor;
29use hyperactor::ActorHandle;
30use hyperactor::ActorRef;
31use hyperactor::Addr;
32use hyperactor::Context;
33use hyperactor::Endpoint as _;
34use hyperactor::HandleClient;
35use hyperactor::Handler;
36use hyperactor::Instance;
37use hyperactor::PortHandle;
38use hyperactor::PortRef;
39use hyperactor::Proc;
40use hyperactor::ProcAddr;
41use hyperactor::RefClient;
42use hyperactor::RemoteEndpoint as _;
43use hyperactor::Uid;
44use hyperactor::actor::ActorStatus;
45use hyperactor::actor::ActorStoppingReason;
46use hyperactor::context;
47use hyperactor::gateway::GatewayServeHandle;
48use hyperactor::id::Label;
49use hyperactor::value_mesh::ValueOverlay;
50use hyperactor_config::Flattrs;
51use hyperactor_config::attrs::Attrs;
52use ndslice::view::Region;
53use serde::Deserialize;
54use serde::Serialize;
55use tokio::time::Duration;
56use typeuri::Named;
57
58use crate::StatusOverlay;
59use crate::bootstrap;
60use crate::bootstrap::BootstrapCommand;
61use crate::bootstrap::BootstrapProcConfig;
62use crate::bootstrap::BootstrapProcManager;
63use crate::bootstrap::ProcBind;
64use crate::config_dump::ConfigDump;
65use crate::config_dump::ConfigDumpResult;
66use crate::host::Host;
67use crate::host::HostError;
68use crate::host::LOCAL_PROC_NAME;
69use crate::host::LocalProcManager;
70use crate::host::SERVICE_PROC_NAME;
71use crate::host::SingleTerminate;
72use crate::mesh_id::HostMeshId;
73use crate::mesh_id::ProcMeshId;
74use crate::mesh_id::ResourceId;
75use crate::proc_agent::ProcAgent;
76use crate::pyspy::PySpyDump;
77use crate::pyspy::PySpyProfile;
78use crate::pyspy::PySpyProfileWorker;
79use crate::pyspy::PySpyWorker;
80use crate::resource;
81use crate::resource::ProcSpec;
82use crate::resource::Status;
83
84pub(crate) type ProcManagerSpawnFuture =
85    Pin<Box<dyn Future<Output = anyhow::Result<ActorHandle<ProcAgent>>> + Send>>;
86pub(crate) type ProcManagerSpawnFn = Box<dyn Fn(Proc) -> ProcManagerSpawnFuture + Send + Sync>;
87
88/// Represents the different ways a [`Host`] can be managed by an agent.
89///
90/// A host can either:
91/// - [`Process`] — a host running as an external OS process, managed by
92///   [`BootstrapProcManager`].
93/// - [`Local`] — a host running in-process, managed by
94///   [`LocalProcManager`] with a custom spawn function.
95///
96/// This abstraction lets the same `HostAgent` work across both
97/// out-of-process and in-process execution modes.
98#[derive(EnumAsInner)]
99pub enum HostAgentMode {
100    Process {
101        host: Host<BootstrapProcManager>,
102        /// If set, the ShutdownHost handler sends the frontend mailbox server
103        /// handle back to the bootstrap loop via this channel once shutdown is
104        /// complete, so the caller can drain it and exit.
105        shutdown_tx: Option<tokio::sync::oneshot::Sender<GatewayServeHandle>>,
106    },
107    Local(Host<LocalProcManager<ProcManagerSpawnFn>>),
108}
109
110impl HostAgentMode {
111    pub(crate) fn addr(&self) -> &hyperactor::channel::ChannelAddr {
112        #[allow(clippy::match_same_arms)]
113        match self {
114            HostAgentMode::Process { host, .. } => host.addr(),
115            HostAgentMode::Local(host) => host.addr(),
116        }
117    }
118
119    pub(crate) fn system_proc(&self) -> &Proc {
120        #[allow(clippy::match_same_arms)]
121        match self {
122            HostAgentMode::Process { host, .. } => host.system_proc(),
123            HostAgentMode::Local(host) => host.system_proc(),
124        }
125    }
126
127    pub(crate) fn local_proc(&self) -> &Proc {
128        #[allow(clippy::match_same_arms)]
129        match self {
130            HostAgentMode::Process { host, .. } => host.local_proc(),
131            HostAgentMode::Local(host) => host.local_proc(),
132        }
133    }
134
135    /// Non-blocking stop: send the stop signal and spawn a background
136    /// task for cleanup. Returns immediately without blocking the
137    /// actor.
138    async fn request_stop(
139        &self,
140        cx: &impl context::Actor,
141        proc: &ProcAddr,
142        timeout: Duration,
143        reason: &str,
144    ) {
145        match self {
146            HostAgentMode::Process { host, .. } => {
147                host.manager().request_stop(cx, proc, timeout, reason).await;
148            }
149            HostAgentMode::Local(host) => {
150                host.manager().request_stop(proc, timeout, reason).await;
151            }
152        }
153    }
154
155    /// Query a proc's lifecycle state, returning both the coarse
156    /// `resource::Status` used by the resource protocol and the
157    /// detailed `bootstrap::ProcStatus` (when available) for callers
158    /// that need process-level detail such as PIDs or exit codes.
159    async fn proc_status(
160        &self,
161        proc_id: &ProcAddr,
162    ) -> (resource::Status, Option<bootstrap::ProcStatus>) {
163        match self {
164            HostAgentMode::Process { host, .. } => match host.manager().status(proc_id).await {
165                Some(proc_status) => (proc_status.clone().into(), Some(proc_status)),
166                None => (resource::Status::Unknown, None),
167            },
168            HostAgentMode::Local(host) => {
169                let status = match host.manager().local_proc_status(proc_id).await {
170                    Some(crate::host::LocalProcStatus::Stopping) => resource::Status::Stopping,
171                    Some(crate::host::LocalProcStatus::Stopped) => resource::Status::Stopped,
172                    None => resource::Status::Running,
173                };
174                (status, None)
175            }
176        }
177    }
178
179    /// The bootstrap command used by the process manager, if any.
180    fn bootstrap_command(&self) -> Option<BootstrapCommand> {
181        match self {
182            HostAgentMode::Process { host, .. } => Some(host.manager().command().clone()),
183            HostAgentMode::Local(_) => None,
184        }
185    }
186}
187
188/// Derive the proc resource name for the proc at the given mesh `rank`.
189///
190/// `rank` is the proc's absolute rank in the proc mesh — its position in the
191/// `host_extent ⊕ per_host` region, i.e. [`crate::proc_mesh::ProcRef::create_rank`].
192/// This is the proc's first-class, mesh-level identity; the `(host, per-host
193/// slot)` layout is an implementation detail of how procs are placed and is
194/// deliberately NOT part of the name. Both the caller and the receiving
195/// [`HostAgent`] derive the same name from `rank`, so the caller can construct
196/// [`crate::proc_mesh::ProcRef`]s before the casted `CreateOrUpdate<ProcSpec>`
197/// messages arrive. The id is a stable function of the proc mesh id and rank,
198/// so every proc in the mesh has a distinct id.
199pub(crate) fn proc_name(proc_mesh_id: &ProcMeshId, rank: usize) -> ResourceId {
200    let label = Label::strip(&format!(
201        "{}-{}",
202        proc_mesh_id
203            .display_label()
204            .map(|label| label.as_str())
205            .unwrap_or("unnamed"),
206        rank
207    ));
208
209    match proc_mesh_id.uid() {
210        Uid::Singleton(_) => ResourceId::singleton(label),
211        Uid::Instance(_, _) => {
212            let mut hasher = DefaultHasher::new();
213            proc_mesh_id.hash(&mut hasher);
214            rank.hash(&mut hasher);
215            ResourceId::new(
216                Uid::Instance(hasher.finish(), Some(label.clone())),
217                Some(label),
218            )
219        }
220    }
221}
222
223#[derive(Debug)]
224pub(crate) struct ProcCreationState {
225    pub(crate) rank: usize,
226    pub(crate) host_mesh_id: Option<HostMeshId>,
227    /// The proc mesh this proc belongs to. Used to scope per-mesh queries like
228    /// `StreamState`, since a host agent can hold procs from multiple meshes.
229    /// Always set for procs spawned through a proc mesh (the cast `SpawnProcs`
230    /// path populates it via `ProcSpec`). `None` only for procs created off that
231    /// path (e.g. the point-to-point `CreateOrUpdate` used by tests/admin),
232    /// which belong to no queryable mesh and are intentionally excluded from
233    /// per-mesh queries.
234    pub(crate) proc_mesh_id: Option<ProcMeshId>,
235    pub(crate) created: Result<(ProcAddr, ActorRef<ProcAgent>), HostError>,
236    /// "Owner is alive" deadline communicated by the controller via
237    /// `KeepaliveGetState`. The host's `SelfCheck` reaper compares against this
238    /// and tears down procs whose owner has stopped extending the keepalive.
239    pub(crate) expiry_time: Option<std::time::SystemTime>,
240}
241
242/// Actor name used when spawning the host mesh agent on the system proc.
243pub const HOST_MESH_AGENT_ACTOR_NAME: &str = "host_agent";
244
245/// Lifecycle state of the host managed by [`HostAgent`].
246enum HostAgentState {
247    /// Waiting for a client to attach. The host is idle and ready
248    /// to accept new proc spawn requests.
249    Detached(HostAgentMode),
250    /// Actively running procs for an attached client.
251    Attached(HostAgentMode),
252    /// Procs are being drained by a DrainWorker. The host has been
253    /// temporarily moved to the worker. The host agent remains
254    /// responsive; min_proc_status() returns Stopping.
255    Draining,
256    /// Host fully shut down.
257    Shutdown,
258}
259
260/// A mesh agent is responsible for managing a host in a [`HostMesh`],
261/// through the resource behaviors defined in [`crate::resource`].
262/// Self-notification sent by bridge tasks when a proc's status changes.
263/// Not exported or registered — only used internally via `PortHandle`.
264#[derive(Debug, Serialize, Deserialize, Named)]
265struct ProcStatusChanged {
266    id: ResourceId,
267}
268
269/// Sent by DrainWorker back to HostAgent when draining completes.
270/// Not exported — delivered locally via PortHandle (no serialization).
271struct DrainComplete {
272    host: HostAgentMode,
273    /// This host's ordinal within the drain cast region.
274    rank: usize,
275    /// Streaming status reply the parent posts the drained overlay to,
276    /// after restoring state.
277    reply: PortRef<crate::StatusOverlay>,
278}
279
280/// Child actor whose only job is to run `host.terminate_children()` in
281/// its `init()`, return the host and ack to the parent via DrainComplete,
282/// and exit. Runs on the same proc as the host agent so it gets its
283/// own `Instance` (required by `terminate_children`).
284#[hyperactor::export(handlers = [])]
285struct DrainWorker {
286    host: Option<HostAgentMode>,
287    timeout: Duration,
288    max_in_flight: usize,
289    rank: usize,
290    reply: Option<PortRef<crate::StatusOverlay>>,
291    done_notify: PortHandle<DrainComplete>,
292}
293
294#[async_trait]
295impl Actor for DrainWorker {
296    async fn init(&mut self, this: &Instance<Self>) -> Result<(), anyhow::Error> {
297        if let Some(host) = self.host.as_mut() {
298            match host {
299                HostAgentMode::Process { host, .. } => {
300                    host.terminate_children(
301                        this,
302                        self.timeout,
303                        self.max_in_flight.clamp(1, 256),
304                        "drain host",
305                    )
306                    .await;
307                }
308                HostAgentMode::Local(host) => {
309                    host.terminate_children(this, self.timeout, self.max_in_flight, "drain host")
310                        .await;
311                }
312            }
313        }
314
315        // Bundle host + reply into DrainComplete so the parent reports the
316        // drained overlay AFTER restoring state (prevents race with
317        // ShutdownHost).
318        if let (Some(host), Some(reply)) = (self.host.take(), self.reply.take()) {
319            let _ = self.done_notify.post(
320                this,
321                DrainComplete {
322                    host,
323                    rank: self.rank,
324                    reply,
325                },
326            );
327        }
328
329        Ok(())
330    }
331}
332
333impl fmt::Debug for DrainWorker {
334    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
335        f.debug_struct("DrainWorker")
336            .field("timeout", &self.timeout)
337            .field("max_in_flight", &self.max_in_flight)
338            .finish()
339    }
340}
341
342#[hyperactor::export(
343    handlers=[
344        resource::CreateOrUpdate<ProcSpec>,
345        SpawnProcs,
346        resource::Stop,
347        resource::GetState<ProcState>,
348        resource::KeepaliveGetState<ProcState>,
349        GetHostProcStates,
350        resource::StreamState<ProcState>,
351        resource::GetRankStatus,
352        resource::WaitRankStatus,
353        resource::List,
354        ShutdownHost,
355        DrainHost,
356        SetClientConfig,
357        ProcStatusChanged,
358        PySpyDump,
359        PySpyProfile,
360        ConfigDump,
361        crate::proc_agent::SelfCheck,
362    ]
363)]
364pub struct HostAgent {
365    state: HostAgentState,
366    pub(crate) created: HashMap<ResourceId, ProcCreationState>,
367    /// Pending `WaitRankStatus` waiters, keyed by resource name.
368    /// Each entry is `(min_status, rank, reply_port)`. Only touched
369    /// from `&mut self` handlers.
370    pending_proc_waiters:
371        HashMap<ResourceId, Vec<(resource::Status, usize, PortRef<crate::StatusOverlay>)>>,
372    /// Procs that already have an active bridge task watching their status.
373    watching: HashSet<ResourceId>,
374    /// Port handle for sending `ProcStatusChanged` to self. Set in `init()`.
375    proc_status_port: Option<PortHandle<ProcStatusChanged>>,
376    /// Lazily initialized ProcAgent on the host's local proc.
377    /// Boots on first [`GetLocalProc`] (LP-1 — see
378    /// `crate::host::LOCAL_PROC_NAME`).
379    local_mesh_agent: OnceLock<anyhow::Result<ActorHandle<ProcAgent>>>,
380}
381
382impl HostAgent {
383    /// Create a host mesh agent for a process-backed host.
384    pub fn new_process(
385        host: Host<BootstrapProcManager>,
386        shutdown_tx: Option<tokio::sync::oneshot::Sender<GatewayServeHandle>>,
387    ) -> Self {
388        Self::new(HostAgentMode::Process { host, shutdown_tx })
389    }
390
391    /// Create a host mesh agent for an in-process host.
392    pub fn new_local(host: Host<LocalProcManager<ProcManagerSpawnFn>>) -> Self {
393        Self::new(HostAgentMode::Local(host))
394    }
395
396    fn new(host: HostAgentMode) -> Self {
397        Self {
398            state: HostAgentState::Detached(host),
399            created: HashMap::new(),
400            pending_proc_waiters: HashMap::new(),
401            watching: HashSet::new(),
402            proc_status_port: None,
403            local_mesh_agent: OnceLock::new(),
404        }
405    }
406
407    /// Wait until the agent has completed `init` and can receive external
408    /// messages through the host gateway.
409    pub async fn wait_initialized(handle: &ActorHandle<Self>) -> anyhow::Result<()> {
410        let mut status = handle.status();
411        loop {
412            let current = status.borrow_and_update().clone();
413            match current {
414                ActorStatus::Idle | ActorStatus::Processing(_, _) => return Ok(()),
415                ActorStatus::Failed(err) => anyhow::bail!("host agent init failed: {err}"),
416                ActorStatus::Stopped(reason) => anyhow::bail!("host agent stopped: {reason}"),
417                ActorStatus::Stopping(ActorStoppingReason::Zombie(reason)) => {
418                    anyhow::bail!("host agent zombie: {reason}")
419                }
420                ActorStatus::Unknown
421                | ActorStatus::Created
422                | ActorStatus::Initializing
423                | ActorStatus::Client
424                | ActorStatus::Stopping(_) => {}
425            }
426            if status.changed().await.is_err() {
427                anyhow::bail!("host agent status channel closed before init completed");
428            }
429        }
430    }
431
432    /// Minimum status floor derived from the host agent's lifecycle.
433    /// Procs on this host cannot be healthier than this.
434    fn min_proc_status(&self) -> resource::Status {
435        match &self.state {
436            HostAgentState::Detached(_) | HostAgentState::Attached(_) => resource::Status::Running,
437            HostAgentState::Draining => resource::Status::Stopping,
438            HostAgentState::Shutdown => resource::Status::Stopped,
439        }
440    }
441
442    fn host(&self) -> Option<&HostAgentMode> {
443        match &self.state {
444            HostAgentState::Detached(h) | HostAgentState::Attached(h) => Some(h),
445            _ => None,
446        }
447    }
448
449    fn host_mut(&mut self) -> Option<&mut HostAgentMode> {
450        match &mut self.state {
451            HostAgentState::Detached(h) | HostAgentState::Attached(h) => Some(h),
452            _ => None,
453        }
454    }
455
456    /// Terminate all tracked children on the host and clear proc state.
457    ///
458    /// The host, system proc, mailbox server, and HostAgent all stay
459    /// alive — only user procs are killed. After this returns the host
460    /// is ready to accept new spawn requests with the same proc names.
461    async fn drain(
462        &mut self,
463        cx: &Context<'_, Self>,
464        timeout: std::time::Duration,
465        max_in_flight: usize,
466    ) {
467        if let Some(host_mode) = self.host_mut() {
468            match host_mode {
469                HostAgentMode::Process { host, .. } => {
470                    let summary = host
471                        .terminate_children(cx, timeout, max_in_flight.clamp(1, 256), "stop host")
472                        .await;
473                    tracing::info!(?summary, "terminated children on host");
474                }
475                HostAgentMode::Local(host) => {
476                    let summary = host
477                        .terminate_children(cx, timeout, max_in_flight, "stop host")
478                        .await;
479                    tracing::info!(?summary, "terminated children on local host");
480                }
481            }
482        }
483        self.created.clear();
484    }
485
486    /// Selectively stop procs belonging to a specific host mesh.
487    /// Only procs whose `host_mesh_id` matches `filter` are stopped;
488    /// all other procs are left running.
489    async fn drain_by_mesh_name(
490        &mut self,
491        cx: &Context<'_, Self>,
492        timeout: std::time::Duration,
493        filter: Option<&HostMeshId>,
494    ) {
495        let matching_ids: Vec<ResourceId> = self
496            .created
497            .iter()
498            .filter(|(_, state)| state.host_mesh_id.as_ref() == filter)
499            .map(|(id, _)| id.clone())
500            .collect();
501
502        if let Some(host_mode) = self.host() {
503            for id in &matching_ids {
504                if let Some(ProcCreationState {
505                    created: Ok((proc_id, _)),
506                    ..
507                }) = self.created.get(id)
508                {
509                    match host_mode {
510                        HostAgentMode::Process { host, .. } => {
511                            let _ = host
512                                .terminate_proc(cx, proc_id, timeout, "selective drain")
513                                .await;
514                        }
515                        HostAgentMode::Local(host) => {
516                            let _ = host
517                                .terminate_proc(cx, proc_id, timeout, "selective drain")
518                                .await;
519                        }
520                    }
521                }
522            }
523        }
524
525        // Remove drained entries and associated state so that
526        // future spawns with the same proc names get fresh watch bridges.
527        for id in &matching_ids {
528            self.created.remove(id);
529            self.watching.remove(id);
530            self.pending_proc_waiters.remove(id);
531        }
532
533        tracing::info!(
534            count = matching_ids.len(),
535            filter = ?filter,
536            "selectively drained procs",
537        );
538    }
539
540    /// Publish the current host properties and child list for
541    /// introspection. Called from init and after each state change
542    /// (proc created/stopped).
543    fn publish_introspect_properties(&self, cx: &Instance<Self>) {
544        let host = match self.host() {
545            Some(h) => h,
546            None => return, // host shut down or stopping
547        };
548
549        let addr = host.addr().to_string();
550        let mut children: Vec<hyperactor::introspect::IntrospectRef> = Vec::new();
551        let system_children: Vec<crate::introspect::NodeRef> = Vec::new(); // LC-2
552
553        // Procs are not system — only actors are. Both service and
554        // local appear as regular children; 's' in the TUI toggles
555        // actor visibility, not proc visibility.
556        children.push(hyperactor::introspect::IntrospectRef::Proc(
557            host.system_proc().proc_addr().clone(),
558        ));
559        children.push(hyperactor::introspect::IntrospectRef::Proc(
560            host.local_proc().proc_addr().clone(),
561        ));
562
563        // User procs.
564        for state in self.created.values() {
565            if let Ok((proc_id, _agent_ref)) = &state.created {
566                children.push(hyperactor::introspect::IntrospectRef::Proc(proc_id.clone()));
567            }
568        }
569
570        let num_procs = children.len();
571
572        let mut attrs = hyperactor_config::Attrs::new();
573        attrs.set(crate::introspect::NODE_TYPE, "host".to_string());
574        attrs.set(crate::introspect::ADDR, addr);
575        attrs.set(crate::introspect::NUM_PROCS, num_procs);
576        attrs.set(hyperactor::introspect::CHILDREN, children);
577        attrs.set(crate::introspect::SYSTEM_CHILDREN, system_children);
578        // PD-*: hosting-process memory stats. This is the same
579        // hosting OS process signal surfaced on the proc path, but
580        // the host path does not attempt to publish proc-local queue
581        // pressure.
582        let memory = crate::introspect::ProcessMemoryStats::read_from_procfs();
583        memory.to_attrs(&mut attrs);
584        cx.publish_attrs(attrs);
585    }
586}
587
588#[async_trait]
589impl Actor for HostAgent {
590    async fn init(&mut self, this: &Instance<Self>) -> Result<(), anyhow::Error> {
591        this.bind::<Self>();
592        if matches!(self.host().unwrap(), HostAgentMode::Process { .. }) {
593            let (directory, file) = hyperactor_telemetry::log_file_path(
594                hyperactor_telemetry::env::Env::current(),
595                None,
596            )
597            .unwrap();
598            eprintln!(
599                "Monarch internal logs are being written to {}/{}.log; execution id {}",
600                directory,
601                file,
602                hyperactor_telemetry::env::execution_id(),
603            );
604        }
605        this.set_system();
606        self.publish_introspect_properties(this);
607
608        // Register callback for QueryChild — resolves system procs
609        // that are not independently addressable actors.
610        let host = self.host().expect("host present");
611        let system_proc = host.system_proc().clone();
612        let local_proc = host.local_proc().clone();
613        let self_id = this.self_addr().clone();
614        this.set_query_child_handler(move |child_ref| {
615            use hyperactor::introspect::IntrospectResult;
616
617            let proc = match child_ref {
618                Addr::Proc(proc_ref) => {
619                    if *proc_ref == system_proc.proc_addr() {
620                        Some((&system_proc, SERVICE_PROC_NAME))
621                    } else if *proc_ref == local_proc.proc_addr() {
622                        Some((&local_proc, LOCAL_PROC_NAME))
623                    } else {
624                        None
625                    }
626                }
627                _ => None,
628            };
629
630            match proc {
631                Some((proc, label)) => {
632                    // Use all_instance_keys() instead of
633                    // all_actor_ids() to avoid holding DashMap shard
634                    // read locks while doing Weak::upgrade() +
635                    // watch::borrow() + is_terminal() per entry.
636                    // Under rapid actor churn the per-entry work in
637                    // all_actor_ids() causes convoy starvation with
638                    // concurrent insert/remove operations, stalling
639                    // the spawn/exit path. all_instance_keys() just
640                    // clones keys — microseconds per shard. Actor
641                    // addresses and the is_system check use individual
642                    // point lookups outside the iteration. Stale keys
643                    // are harmless: if the point lookup fails, the actor
644                    // has already gone away.
645                    let all_keys = proc.all_instance_keys();
646                    let mut actors: Vec<hyperactor::introspect::IntrospectRef> =
647                        Vec::with_capacity(all_keys.len());
648                    let mut system_actors: Vec<crate::introspect::NodeRef> = Vec::new();
649                    for id in all_keys {
650                        if let Some(cell) = proc.get_instance_by_id(&id) {
651                            let actor_addr = cell.actor_addr().clone();
652                            if cell.is_system() {
653                                system_actors
654                                    .push(crate::introspect::NodeRef::Actor(actor_addr.clone()));
655                            }
656                            actors.push(hyperactor::introspect::IntrospectRef::Actor(actor_addr));
657                        }
658                    }
659                    let mut attrs = hyperactor_config::Attrs::new();
660                    attrs.set(crate::introspect::NODE_TYPE, "proc".to_string());
661                    attrs.set(crate::introspect::PROC_NAME, label.to_string());
662                    attrs.set(crate::introspect::NUM_ACTORS, actors.len());
663                    attrs.set(crate::introspect::SYSTEM_CHILDREN, system_actors.clone());
664                    // PD-*: include proc debug stats so QueryChild
665                    // results carry real signal. Memory from procfs,
666                    // queue stats from the Proc's runtime accounting.
667                    let memory = crate::introspect::ProcessMemoryStats::read_from_procfs();
668                    memory.to_attrs(&mut attrs);
669                    attrs.set(
670                        crate::introspect::ACTOR_WORK_QUEUE_DEPTH_TOTAL,
671                        proc.queue_depth_total(),
672                    );
673                    // Per-actor max from the live actor scan.
674                    let mut queue_max: u64 = 0;
675                    for aid in proc.all_instance_keys() {
676                        if let Some(cell) = proc.get_instance_by_id(&aid) {
677                            queue_max = queue_max.max(cell.queue_depth());
678                        }
679                    }
680                    attrs.set(crate::introspect::ACTOR_WORK_QUEUE_DEPTH_MAX, queue_max);
681                    attrs.set(
682                        crate::introspect::ACTOR_WORK_QUEUE_DEPTH_HIGH_WATER_MARK,
683                        proc.queue_depth_high_water_mark(),
684                    );
685                    attrs.set(
686                        crate::introspect::LAST_NONZERO_QUEUE_DEPTH_AGE_MS,
687                        proc.last_nonzero_queue_depth_age_ms(),
688                    );
689                    let attrs_json =
690                        serde_json::to_string(&attrs).unwrap_or_else(|_| "{}".to_string());
691
692                    IntrospectResult {
693                        identity: hyperactor::introspect::IntrospectRef::Proc(
694                            proc.proc_addr().clone(),
695                        ),
696                        attrs: attrs_json,
697                        children: actors,
698                        parent: Some(hyperactor::introspect::IntrospectRef::Actor(
699                            self_id.clone(),
700                        )),
701                        as_of: std::time::SystemTime::now(),
702                    }
703                }
704                None => {
705                    let mut error_attrs = hyperactor_config::Attrs::new();
706                    error_attrs.set(hyperactor::introspect::ERROR_CODE, "not_found".to_string());
707                    error_attrs.set(
708                        hyperactor::introspect::ERROR_MESSAGE,
709                        format!("child {} not found", child_ref),
710                    );
711                    let identity = match child_ref {
712                        Addr::Proc(p) => hyperactor::introspect::IntrospectRef::Proc(p.clone()),
713                        Addr::Actor(a) => hyperactor::introspect::IntrospectRef::Actor(a.clone()),
714                        Addr::Port(p) => {
715                            hyperactor::introspect::IntrospectRef::Actor(p.actor_addr())
716                        }
717                    };
718                    IntrospectResult {
719                        identity,
720                        attrs: serde_json::to_string(&error_attrs)
721                            .unwrap_or_else(|_| "{}".to_string()),
722                        children: Vec::new(),
723                        parent: None,
724                        as_of: std::time::SystemTime::now(),
725                    }
726                }
727            }
728        });
729
730        self.proc_status_port = Some(this.port::<ProcStatusChanged>());
731
732        // Kick off the SelfCheck reaper if the orphan timeout is configured.
733        // The reaper walks `created` looking for procs whose owner stopped
734        // extending the keepalive and tears them down.
735        if let Some(delay) = hyperactor_config::global::get(crate::proc_agent::MESH_ORPHAN_TIMEOUT)
736        {
737            this.post_after(this, crate::proc_agent::SelfCheck::default(), delay);
738        }
739
740        Ok(())
741    }
742}
743
744impl fmt::Debug for HostAgent {
745    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
746        f.debug_struct("HostAgent")
747            .field("host", &"..")
748            .field("created", &self.created)
749            .finish()
750    }
751}
752
753/// Cast to every HostAgent to spawn a mesh's per-host proc slots in one message.
754/// Identity is positional: each recipient derives its procs' ids/ranks from its
755/// stamped `rank` plus `proc_mesh_id`/`num_per_host`, since a shared cast payload
756/// can't carry a per-host `id` (unlike `CreateOrUpdate`).
757#[derive(
758    Serialize,
759    Deserialize,
760    Clone,
761    Debug,
762    Named,
763    Handler,
764    RefClient,
765    HandleClient
766)]
767pub struct SpawnProcs {
768    /// This host's ordinal within the cast region, stamped by the cast layer.
769    pub rank: resource::Rank,
770    /// Proc mesh id used to deterministically derive per-host proc ids.
771    pub proc_mesh_id: ProcMeshId,
772    /// Number of procs to spawn on this host.
773    pub num_per_host: usize,
774    /// Config values to set on each spawned proc's global config, at the
775    /// `ClientOverride` layer.
776    pub client_config_override: Attrs,
777    /// The HostMesh that owns these procs (used by `DrainHost` for selective
778    /// drain).
779    pub host_mesh_id: Option<HostMeshId>,
780    /// Bootstrap command to use when no per-rank override applies.
781    pub default_bootstrap_command: Option<BootstrapCommand>,
782    /// Optional per-proc CPU/NUMA binding, indexed by per-host slot. `HostMesh::spawn`
783    /// already rejects a length that doesn't match the per-host extent, so the
784    /// handler is deliberately tolerant of a missing slot (including an index
785    /// past the vec's length): it spawns with no binding rather than panicking.
786    pub proc_bind: Option<Vec<ProcBind>>,
787    /// Optional per-rank bootstrap overrides, indexed by absolute proc rank
788    /// (`num_per_host * host_rank + per_host_rank`).
789    pub bootstrap_commands: Option<Vec<Option<BootstrapCommand>>>,
790    /// Spawn ack: the host posts one multi-rank overlay covering all of its
791    /// procs; the caller reduces these per-host overlays into a `StatusMesh`
792    /// barrier.
793    #[serde(default)]
794    pub status_reply: Option<PortRef<crate::StatusOverlay>>,
795}
796wirevalue::register_type!(SpawnProcs);
797
798#[async_trait]
799impl Handler<SpawnProcs> for HostAgent {
800    #[tracing::instrument("HostAgent::SpawnProcs", level = "info", skip_all, fields(host_rank, num = spawn.num_per_host))]
801    async fn handle(&mut self, cx: &Context<Self>, spawn: SpawnProcs) -> anyhow::Result<()> {
802        let host_rank = spawn
803            .rank
804            .0
805            .expect("cast layer stamps the rank before delivery");
806
807        tracing::Span::current().record("host_rank", host_rank);
808
809        let mut spawn_result = Ok(());
810
811        for per_host_rank in 0..spawn.num_per_host {
812            let rank = spawn.num_per_host * host_rank + per_host_rank;
813
814            let id = proc_name(&spawn.proc_mesh_id, rank);
815
816            let bootstrap_command = spawn
817                .bootstrap_commands
818                .as_ref()
819                .and_then(|commands| commands.get(rank).cloned().flatten())
820                .or_else(|| spawn.default_bootstrap_command.clone());
821
822            let proc_bind = spawn
823                .proc_bind
824                .as_ref()
825                .and_then(|binds| binds.get(per_host_rank).cloned());
826
827            if let Err(e) = <Self as Handler<resource::CreateOrUpdate<ProcSpec>>>::handle(
828                self,
829                cx,
830                resource::CreateOrUpdate {
831                    id,
832                    rank: resource::Rank::new(rank),
833                    spec: ProcSpec {
834                        client_config_override: spawn.client_config_override.clone(),
835                        proc_bind,
836                        bootstrap_command,
837                        host_mesh_id: spawn.host_mesh_id.clone(),
838                        proc_mesh_id: Some(spawn.proc_mesh_id.clone()),
839                    },
840                },
841            )
842            .await
843            {
844                // Stop spawning, but fall through to report the result below.
845                spawn_result = Err(e);
846                break;
847            }
848        }
849
850        // Report this host's full rank range in a single multi-rank overlay. The
851        // caller's readiness barrier only completes once *every* rank has moved
852        // off NotExist, so on the error path the ranks we never created are
853        // reported as Failed too — otherwise the caller would wait out its whole
854        // idle timeout instead of failing fast on the error we return below.
855        if let Some(reply) = &spawn.status_reply {
856            let mut runs = Vec::with_capacity(spawn.num_per_host);
857
858            for per_host_rank in 0..spawn.num_per_host {
859                let rank = spawn.num_per_host * host_rank + per_host_rank;
860
861                let id = proc_name(&spawn.proc_mesh_id, rank);
862
863                let status = match self.proc_rank_status(&id).await {
864                    (resolved, status) if resolved != usize::MAX => status,
865                    // Unknown to this host: not yet attempted, or its creation
866                    // errored before being recorded. Mark Failed on the error
867                    // path; on success every rank is created so this is moot.
868                    _ => match &spawn_result {
869                        Err(e) => Status::Failed(e.to_string()),
870                        Ok(()) => continue,
871                    },
872                };
873
874                runs.push((rank..(rank + 1), status));
875            }
876
877            reply.post(cx, crate::StatusOverlay::try_from_runs(runs)?);
878        }
879
880        spawn_result
881    }
882}
883
884// Point-to-point proc spawn by carried `id`. The shared per-proc creation path:
885// invoked directly (e.g. unit/admin tests) and once per slot by the `SpawnProcs`
886// cast handler, which derives each proc's id/rank from the host's stamped rank.
887#[async_trait]
888impl Handler<resource::CreateOrUpdate<ProcSpec>> for HostAgent {
889    #[tracing::instrument("HostAgent::CreateOrUpdate", level = "info", skip_all, fields(id=%create_or_update.id))]
890    async fn handle(
891        &mut self,
892        cx: &Context<Self>,
893        create_or_update: resource::CreateOrUpdate<ProcSpec>,
894    ) -> anyhow::Result<()> {
895        if self.created.contains_key(&create_or_update.id) {
896            // Already created: there is no update.
897            return Ok(());
898        }
899
900        let host = match self.host_mut() {
901            Some(h) => h,
902            None => {
903                tracing::warn!(
904                    id = %create_or_update.id,
905                    "ignoring CreateOrUpdate: HostAgent has already shut down"
906                );
907                return Ok(());
908            }
909        };
910        let created = match host {
911            HostAgentMode::Process { host, .. } => {
912                host.spawn(
913                    create_or_update.id.to_string(),
914                    BootstrapProcConfig {
915                        create_rank: create_or_update.rank.unwrap(),
916                        client_config_override: create_or_update
917                            .spec
918                            .client_config_override
919                            .clone(),
920                        proc_bind: create_or_update.spec.proc_bind.clone(),
921                        bootstrap_command: create_or_update.spec.bootstrap_command.clone(),
922                    },
923                )
924                .await
925            }
926            HostAgentMode::Local(host) => host.spawn(create_or_update.id.to_string(), ()).await,
927        };
928
929        let rank = create_or_update.rank.unwrap();
930
931        if let Err(e) = &created {
932            tracing::error!("failed to spawn proc {}: {}", create_or_update.id, e);
933        }
934        let was_empty = self.created.is_empty();
935        self.created.insert(
936            create_or_update.id.clone(),
937            ProcCreationState {
938                rank,
939                host_mesh_id: create_or_update.spec.host_mesh_id.clone(),
940                proc_mesh_id: create_or_update.spec.proc_mesh_id.clone(),
941                created,
942                expiry_time: None,
943            },
944        );
945
946        // Transition Detached → Attached on first proc creation.
947        if was_empty && let HostAgentState::Detached(_) = &self.state {
948            let host = match std::mem::replace(&mut self.state, HostAgentState::Shutdown) {
949                HostAgentState::Detached(h) => h,
950                _ => unreachable!(),
951            };
952            self.state = HostAgentState::Attached(host);
953        }
954
955        // If any WaitRankStatus messages arrived before this proc
956        // existed, their waiters were stashed with a sentinel rank.
957        // Now that we know the real rank, fix them up and start a
958        // watch bridge.
959        // Extract the proc_id before mutably borrowing pending_proc_waiters.
960        let proc_id = self
961            .created
962            .get(&create_or_update.id)
963            .and_then(|s| s.created.as_ref().ok())
964            .map(|(pid, _)| pid.clone());
965
966        if let Some(waiters) = self.pending_proc_waiters.get_mut(&create_or_update.id) {
967            for (_, waiter_rank, _) in waiters.iter_mut() {
968                if *waiter_rank == usize::MAX {
969                    *waiter_rank = rank;
970                }
971            }
972        }
973
974        // Start a bridge and send ourselves an initial check.
975        if self.pending_proc_waiters.contains_key(&create_or_update.id) {
976            if let Some(proc_id) = &proc_id {
977                self.start_watch_bridge(&create_or_update.id, proc_id).await;
978            }
979            self.flush_proc_waiters(cx, &create_or_update.id).await;
980        }
981
982        self.publish_introspect_properties(cx);
983        Ok(())
984    }
985}
986
987#[async_trait]
988impl Handler<resource::Stop> for HostAgent {
989    async fn handle(&mut self, cx: &Context<Self>, message: resource::Stop) -> anyhow::Result<()> {
990        tracing::info!(
991            name = "HostMeshAgentStatus",
992            proc_id = %message.id,
993            reason = %message.reason,
994            "stopping proc"
995        );
996        let host = match self.host() {
997            Some(h) => h,
998            None => {
999                // Host already shut down; all procs are terminated.
1000                tracing::debug!(
1001                    proc_id = %message.id,
1002                    "ignoring Stop: HostAgent has already shut down"
1003                );
1004                return Ok(());
1005            }
1006        };
1007        let timeout = hyperactor_config::global::get(hyperactor::config::PROCESS_EXIT_TIMEOUT);
1008
1009        if let Some(ProcCreationState {
1010            created: Ok((proc_id, _)),
1011            ..
1012        }) = self.created.get(&message.id)
1013        {
1014            host.request_stop(cx, proc_id, timeout, &message.reason)
1015                .await;
1016        }
1017
1018        // Status may have changed to Stopping; notify pending waiters.
1019        self.flush_proc_waiters(cx, &message.id).await;
1020
1021        self.publish_introspect_properties(cx);
1022        Ok(())
1023    }
1024}
1025
1026impl HostAgent {
1027    /// The `(rank, status)` for a created proc, clamped to the host's minimum
1028    /// status. `rank == usize::MAX` means the proc is unknown to this host.
1029    async fn proc_rank_status(&self, id: &ResourceId) -> (usize, Status) {
1030        match self.created.get(id) {
1031            Some(ProcCreationState {
1032                rank,
1033                created: Ok((proc_id, _mesh_agent)),
1034                ..
1035            }) => {
1036                let raw_status = match self.host() {
1037                    Some(host) => host.proc_status(proc_id).await.0,
1038                    None => resource::Status::Unknown,
1039                };
1040                (*rank, raw_status.clamp_min(self.min_proc_status()))
1041            }
1042            Some(ProcCreationState {
1043                rank,
1044                created: Err(e),
1045                ..
1046            }) => (*rank, Status::Failed(e.to_string())),
1047            None => (usize::MAX, Status::NotExist),
1048        }
1049    }
1050}
1051
1052#[async_trait]
1053impl Handler<resource::GetRankStatus> for HostAgent {
1054    async fn handle(
1055        &mut self,
1056        cx: &Context<Self>,
1057        get_rank_status: resource::GetRankStatus,
1058    ) -> anyhow::Result<()> {
1059        let (rank, status) = self.proc_rank_status(&get_rank_status.id).await;
1060
1061        let overlay = if rank == usize::MAX {
1062            StatusOverlay::new()
1063        } else {
1064            StatusOverlay::try_from_runs(vec![(rank..(rank + 1), status)])
1065                .expect("valid single-run overlay")
1066        };
1067        get_rank_status.reply.post(cx, overlay);
1068        Ok(())
1069    }
1070}
1071
1072#[async_trait]
1073impl Handler<resource::WaitRankStatus> for HostAgent {
1074    async fn handle(
1075        &mut self,
1076        cx: &Context<Self>,
1077        msg: resource::WaitRankStatus,
1078    ) -> anyhow::Result<()> {
1079        use crate::StatusOverlay;
1080        use crate::resource::Status;
1081
1082        match self.created.get(&msg.id) {
1083            Some(ProcCreationState {
1084                rank,
1085                created: Ok((proc_id, _)),
1086                ..
1087            }) => {
1088                let rank = *rank;
1089                let status = match self.host() {
1090                    Some(host) => host.proc_status(proc_id).await.0,
1091                    None => Status::Stopped,
1092                };
1093
1094                // If already at or past the requested threshold, reply immediately.
1095                if status >= msg.min_status {
1096                    let overlay = StatusOverlay::try_from_runs(vec![(rank..(rank + 1), status)])
1097                        .expect("valid single-run overlay");
1098                    let _ = msg.reply.post(cx, overlay);
1099                    return Ok(());
1100                }
1101
1102                // Stash the waiter and start a bridge if we don't have one yet.
1103                self.pending_proc_waiters
1104                    .entry(msg.id.clone())
1105                    .or_default()
1106                    .push((msg.min_status, rank, msg.reply));
1107
1108                let proc_id = proc_id.clone();
1109                self.start_watch_bridge(&msg.id, &proc_id).await;
1110            }
1111            Some(ProcCreationState {
1112                rank,
1113                created: Err(e),
1114                ..
1115            }) => {
1116                // Creation failed — reply immediately with Failed status.
1117                let overlay = StatusOverlay::try_from_runs(vec![(
1118                    *rank..(*rank + 1),
1119                    Status::Failed(e.to_string()),
1120                )])
1121                .expect("valid single-run overlay");
1122                let _ = msg.reply.post(cx, overlay);
1123            }
1124            None => {
1125                // Proc doesn't exist yet. Stash the waiter with a
1126                // sentinel rank; CreateOrUpdate will fill it in and
1127                // start the watch bridge.
1128                self.pending_proc_waiters
1129                    .entry(msg.id.clone())
1130                    .or_default()
1131                    .push((msg.min_status, usize::MAX, msg.reply));
1132            }
1133        }
1134
1135        Ok(())
1136    }
1137}
1138
1139#[async_trait]
1140impl Handler<ProcStatusChanged> for HostAgent {
1141    async fn handle(&mut self, cx: &Context<Self>, msg: ProcStatusChanged) -> anyhow::Result<()> {
1142        self.flush_proc_waiters(cx, &msg.id).await;
1143        Ok(())
1144    }
1145}
1146
1147impl HostAgent {
1148    /// Flush pending `WaitRankStatus` waiters whose threshold is now satisfied.
1149    async fn flush_proc_waiters(&mut self, cx: &Context<'_, Self>, id: &ResourceId) {
1150        use crate::StatusOverlay;
1151        use crate::resource::Status;
1152
1153        let status = match self.created.get(id) {
1154            Some(ProcCreationState {
1155                created: Ok((proc_id, _)),
1156                ..
1157            }) => match self.host() {
1158                Some(host) => host.proc_status(proc_id).await.0,
1159                None => Status::Stopped,
1160            },
1161            Some(ProcCreationState {
1162                created: Err(_), ..
1163            }) => {
1164                // Already replied with Failed when they were stashed.
1165                return;
1166            }
1167            None => {
1168                // Proc not created yet, nothing to flush.
1169                return;
1170            }
1171        };
1172
1173        let Some(waiters) = self.pending_proc_waiters.get_mut(id) else {
1174            return;
1175        };
1176
1177        let remaining = std::mem::take(waiters);
1178        for (min_status, rank, reply) in remaining {
1179            if status >= min_status {
1180                let overlay =
1181                    StatusOverlay::try_from_runs(vec![(rank..(rank + 1), status.clone())])
1182                        .expect("valid single-run overlay");
1183                let _ = reply.post(cx, overlay);
1184            } else {
1185                waiters.push((min_status, rank, reply));
1186            }
1187        }
1188
1189        if waiters.is_empty() {
1190            self.pending_proc_waiters.remove(id);
1191        }
1192    }
1193
1194    /// Start a bridge task that watches a proc's status channel and sends
1195    /// `ProcStatusChanged` to self on each change. At most one bridge per proc.
1196    async fn start_watch_bridge(&mut self, id: &ResourceId, proc_id: &ProcAddr) {
1197        if self.watching.contains(id) {
1198            return;
1199        }
1200        self.watching.insert(id.clone());
1201
1202        let port = match &self.proc_status_port {
1203            Some(p) => p.clone(),
1204            None => return,
1205        };
1206
1207        match self.host() {
1208            Some(HostAgentMode::Process { host, .. }) => {
1209                if let Some(rx) = host.manager().watch(proc_id).await {
1210                    start_proc_watch(port, rx, id.clone(), |s| s.clone().into());
1211                }
1212            }
1213            Some(HostAgentMode::Local(host)) => {
1214                if let Some(rx) = host.manager().watch(proc_id).await {
1215                    start_proc_watch(port, rx, id.clone(), |s| (*s).into());
1216                }
1217            }
1218            None => {}
1219        }
1220    }
1221}
1222
1223/// Spawn a bridge task that watches a proc's status channel and sends
1224/// `ProcStatusChanged` to the actor via the given `PortHandle`.
1225fn start_proc_watch<S>(
1226    port: PortHandle<ProcStatusChanged>,
1227    mut rx: tokio::sync::watch::Receiver<S>,
1228    id: ResourceId,
1229    to_status: impl Fn(&S) -> resource::Status + Send + 'static,
1230) where
1231    S: Send + Sync + 'static,
1232{
1233    // TODO: replace Instance::self_client() with a proper mechanism
1234    // for sending to port handles without an actor context.
1235    let client = Instance::<()>::self_client();
1236    tokio::spawn(async move {
1237        loop {
1238            match rx.changed().await {
1239                Ok(()) => {
1240                    let status = to_status(&*rx.borrow());
1241                    let terminated = status.is_terminated();
1242                    let _ = port.post(client, ProcStatusChanged { id: id.clone() });
1243                    if terminated {
1244                        return;
1245                    }
1246                }
1247                Err(_) => {
1248                    let _ = port.post(client, ProcStatusChanged { id: id.clone() });
1249                    return;
1250                }
1251            }
1252        }
1253    });
1254}
1255
1256#[derive(
1257    Serialize,
1258    Deserialize,
1259    Clone,
1260    Debug,
1261    Named,
1262    Handler,
1263    RefClient,
1264    HandleClient
1265)]
1266pub struct ShutdownHost {
1267    /// Grace window: send SIGTERM and wait this long before
1268    /// escalating.
1269    pub timeout: std::time::Duration,
1270    /// Max number of children to terminate concurrently on this host.
1271    pub max_in_flight: usize,
1272    /// This host's ordinal within the shutdown cast region, stamped by the
1273    /// cast layer. The host echoes it back via `ack` so the caller can tell
1274    /// exactly which hosts acknowledged shutdown.
1275    pub rank: resource::Rank,
1276    /// Direct reply carrying this host's rank once shutdown work is done.
1277    ///
1278    /// Intentionally must NOT be split/tree-reduced: `ShutdownHost` makes each
1279    /// host exit right after acking, so a tree-reduced ack would stall — the
1280    /// node that fans in peers' acks tears down before they arrive. Replying
1281    /// directly to the caller survives the responders exiting. The caller binds
1282    /// this port `.unsplit()` so the cast layer leaves it alone, and collects
1283    /// one direct reply per host (see `HostMeshRef::cast_shutdown`).
1284    pub ack: PortRef<usize>,
1285}
1286wirevalue::register_type!(ShutdownHost);
1287
1288/// Drain user procs on this host but keep the host, service proc,
1289/// and networking alive. Used during mesh stop/shutdown so that
1290/// forwarder flushes can still reach remote hosts.
1291///
1292/// If `host_mesh_id` is `Some`, only procs belonging to that mesh
1293/// are stopped (selective drain). If `None`, all procs are
1294/// terminated (full drain).
1295#[derive(
1296    Serialize,
1297    Deserialize,
1298    Clone,
1299    Debug,
1300    Named,
1301    Handler,
1302    RefClient,
1303    HandleClient
1304)]
1305pub struct DrainHost {
1306    pub timeout: std::time::Duration,
1307    pub max_in_flight: usize,
1308    pub host_mesh_id: Option<HostMeshId>,
1309    /// The recipient's ordinal within the drain cast region, stamped by
1310    /// the cast layer. Used to position this host's status overlay.
1311    pub rank: resource::Rank,
1312    /// Streaming status reply. Each host reports a single-rank `Stopped`
1313    /// overlay once it has drained; the caller reduces these into a
1314    /// `StatusMesh` barrier and can detect hosts that never reported.
1315    pub reply: PortRef<crate::StatusOverlay>,
1316}
1317wirevalue::register_type!(DrainHost);
1318
1319#[async_trait]
1320impl Handler<DrainHost> for HostAgent {
1321    async fn handle(&mut self, cx: &Context<Self>, msg: DrainHost) -> anyhow::Result<()> {
1322        let rank = msg.rank.unwrap();
1323        // This host's drain completion, as a single-rank `Stopped` overlay at
1324        // its ordinal. The caller reduces these into a StatusMesh barrier.
1325        let drained_overlay = || {
1326            crate::StatusOverlay::try_from_runs(vec![(rank..(rank + 1), resource::Status::Stopped)])
1327                .expect("valid single-run overlay")
1328        };
1329
1330        if msg.host_mesh_id.is_some() {
1331            // Selective drain: stop only procs belonging to the named mesh.
1332            self.drain_by_mesh_name(cx, msg.timeout, msg.host_mesh_id.as_ref())
1333                .await;
1334            msg.reply.post(cx, drained_overlay());
1335            return Ok(());
1336        }
1337
1338        // Full drain: terminate all children.
1339        let host = match std::mem::replace(&mut self.state, HostAgentState::Draining) {
1340            HostAgentState::Attached(h) => h,
1341            other @ (HostAgentState::Detached(_) | HostAgentState::Draining) => {
1342                // Nothing to drain — report immediately.
1343                self.state = other;
1344                msg.reply.post(cx, drained_overlay());
1345                return Ok(());
1346            }
1347            HostAgentState::Shutdown => {
1348                self.state = HostAgentState::Shutdown;
1349                msg.reply.post(cx, drained_overlay());
1350                return Ok(());
1351            }
1352        };
1353
1354        // Do NOT clear `self.created` here: the DrainWorker
1355        // terminates procs asynchronously, and concurrent GetState /
1356        // GetRankStatus queries must still find the entries. With the
1357        // host in Draining state (`self.host()` returns None), those
1358        // handlers already report Status::Stopped for every known
1359        // proc, which is the correct answer while draining is
1360        // in progress.
1361
1362        let done_port = cx.port::<DrainComplete>();
1363
1364        cx.spawn_with_label(
1365            "drain_worker",
1366            DrainWorker {
1367                host: Some(host),
1368                timeout: msg.timeout,
1369                max_in_flight: msg.max_in_flight,
1370                rank,
1371                reply: Some(msg.reply),
1372                done_notify: done_port,
1373            },
1374        );
1375
1376        Ok(())
1377    }
1378}
1379
1380#[async_trait]
1381impl Handler<DrainComplete> for HostAgent {
1382    async fn handle(&mut self, cx: &Context<Self>, msg: DrainComplete) -> anyhow::Result<()> {
1383        self.state = HostAgentState::Detached(msg.host);
1384        self.created.clear();
1385        let overlay = crate::StatusOverlay::try_from_runs(vec![(
1386            msg.rank..(msg.rank + 1),
1387            resource::Status::Stopped,
1388        )])
1389        .expect("valid single-run overlay");
1390        msg.reply.post(cx, overlay);
1391        Ok(())
1392    }
1393}
1394
1395#[async_trait]
1396impl Handler<ShutdownHost> for HostAgent {
1397    async fn handle(&mut self, cx: &Context<Self>, msg: ShutdownHost) -> anyhow::Result<()> {
1398        let rank = msg.rank.unwrap();
1399        // Terminate children BEFORE acking, so the caller's networking
1400        // stays alive while children flush their forwarders during
1401        // teardown. If we ack first, the caller proceeds to tear down
1402        // the host proc's networking while children are still running,
1403        // causing their forwarder flushes to hang until
1404        // MESSAGE_DELIVERY_TIMEOUT expires.
1405        if !self.created.is_empty() {
1406            self.drain(cx, msg.timeout, msg.max_in_flight).await;
1407        }
1408
1409        // Reply this host's rank after children are terminated so the
1410        // caller does not tear down the host's networking prematurely.
1411        msg.ack.post(cx, rank);
1412
1413        // Drop the host and signal the bootstrap loop to drain the
1414        // mailbox and exit.
1415        match std::mem::replace(&mut self.state, HostAgentState::Shutdown) {
1416            HostAgentState::Detached(HostAgentMode::Process {
1417                mut host,
1418                shutdown_tx: Some(tx),
1419            })
1420            | HostAgentState::Attached(HostAgentMode::Process {
1421                mut host,
1422                shutdown_tx: Some(tx),
1423            }) => {
1424                tracing::info!(
1425                    proc_id = %cx.self_addr().proc_addr(),
1426                    actor_id = %cx.self_addr(),
1427                    "host is shut down, sending mailbox handle to bootstrap for draining"
1428                );
1429                if let Some(handle) = host.take_frontend_handle()
1430                    && let Err(mut handle) = tx.send(handle)
1431                {
1432                    handle.stop("bootstrap shutdown receiver dropped");
1433                }
1434            }
1435            _ => {}
1436        }
1437
1438        Ok(())
1439    }
1440}
1441
1442#[derive(Debug, Clone, PartialEq, Eq, Named, Serialize, Deserialize)]
1443pub struct ProcState {
1444    pub proc_id: ProcAddr,
1445    pub create_rank: usize,
1446    pub mesh_agent: ActorRef<ProcAgent>,
1447    pub bootstrap_command: Option<BootstrapCommand>,
1448    pub proc_status: Option<bootstrap::ProcStatus>,
1449}
1450wirevalue::register_type!(ProcState);
1451
1452impl HostAgent {
1453    /// Build the `State<ProcState>` for a single proc `id` from `created`.
1454    /// Shared by the `GetState` and `GetHostProcStates` handlers.
1455    async fn proc_state(&self, id: &ResourceId) -> resource::State<ProcState> {
1456        match self.created.get(id) {
1457            Some(state) => self.proc_state_from(id, state).await,
1458            None => resource::State {
1459                id: id.clone(),
1460                status: resource::Status::NotExist,
1461                state: None,
1462                generation: 0,
1463                timestamp: std::time::SystemTime::now(),
1464            },
1465        }
1466    }
1467
1468    /// Like [`Self::proc_state`], but for an already-borrowed `ProcCreationState`
1469    /// so callers iterating `self.created` avoid a second lookup for the same
1470    /// entry.
1471    async fn proc_state_from(
1472        &self,
1473        id: &ResourceId,
1474        state: &ProcCreationState,
1475    ) -> resource::State<ProcState> {
1476        match state {
1477            ProcCreationState {
1478                rank,
1479                created: Ok((proc_id, mesh_agent)),
1480                ..
1481            } => {
1482                let (raw_status, proc_status, bootstrap_command) = match self.host() {
1483                    Some(host) => {
1484                        let (status, proc_status) = host.proc_status(proc_id).await;
1485                        (status, proc_status, host.bootstrap_command())
1486                    }
1487                    None => (resource::Status::Unknown, None, None),
1488                };
1489                let status = raw_status.clamp_min(self.min_proc_status());
1490                resource::State {
1491                    id: id.clone(),
1492                    status,
1493                    state: Some(ProcState {
1494                        proc_id: proc_id.clone(),
1495                        create_rank: *rank,
1496                        mesh_agent: mesh_agent.clone(),
1497                        bootstrap_command,
1498                        proc_status,
1499                    }),
1500                    generation: 0,
1501                    timestamp: std::time::SystemTime::now(),
1502                }
1503            }
1504            ProcCreationState {
1505                created: Err(e), ..
1506            } => resource::State {
1507                id: id.clone(),
1508                status: resource::Status::Failed(e.to_string()),
1509                state: None,
1510                generation: 0,
1511                timestamp: std::time::SystemTime::now(),
1512            },
1513        }
1514    }
1515}
1516
1517#[async_trait]
1518impl Handler<resource::GetState<ProcState>> for HostAgent {
1519    async fn handle(
1520        &mut self,
1521        cx: &Context<Self>,
1522        get_state: resource::GetState<ProcState>,
1523    ) -> anyhow::Result<()> {
1524        let state = self.proc_state(&get_state.id).await;
1525        get_state.reply.post(cx, state);
1526        Ok(())
1527    }
1528}
1529
1530/// Query the state of a proc mesh's procs, cast to the host agents backing that
1531/// mesh. The caller casts ONE of these carrying the queried `region` (the mesh
1532/// may be sliced, so the region need not be a dense `0..n`). The cast reaches
1533/// every routing host, but each rank's proc lives on exactly one host, so each
1534/// `HostAgent` that owns at least one selected proc reports those procs in a
1535/// single batch. Hosts that own no selected procs do not reply.
1536///
1537/// The bound `reply` port is split by the cast tree, so replies reduce up the
1538/// tree (to cast actor 0) instead of every host dialing the caller directly.
1539/// One batched reply per owning host means the caller sees `O(hosts)` reply
1540/// messages instead of `O(procs)`.
1541///
1542/// `GetState<ProcState>` cannot be cast this way because it carries a
1543/// fully-resolved id; this message resolves ids host-side instead.
1544///
1545/// If `keepalive` is `Some`, each proc's expiry is extended (same
1546/// orphan-protection semantics as `KeepaliveGetState`).
1547#[derive(Debug, Clone, Serialize, Deserialize, Named)]
1548pub struct GetHostProcStates {
1549    pub proc_mesh_id: ProcMeshId,
1550    /// The (possibly sliced) region being queried. Each host keeps the procs it
1551    /// owns for this mesh whose global rank lies in the region, tested with
1552    /// `Slice::contains` (offset/stride-aware) — so a sliced or host-offset
1553    /// region resolves correctly without relying on the recipient's stamped rank.
1554    pub region: Region,
1555    pub keepalive: Option<std::time::SystemTime>,
1556    /// Sparse overlay of the ranks this host owns for the mesh. The caller opens
1557    /// an accumulator port seeded with a full-region template, so per-host
1558    /// overlays reduce up the cast tree into the complete proc-state mesh (see
1559    /// `ProcMeshRef::states`).
1560    pub reply: hyperactor::PortRef<ValueOverlay<resource::State<ProcState>>>,
1561}
1562wirevalue::register_type!(GetHostProcStates);
1563
1564#[async_trait]
1565impl Handler<GetHostProcStates> for HostAgent {
1566    async fn handle(
1567        &mut self,
1568        cx: &Context<Self>,
1569        message: GetHostProcStates,
1570    ) -> anyhow::Result<()> {
1571        let selects = |state: &ProcCreationState| {
1572            state.proc_mesh_id.as_ref() == Some(&message.proc_mesh_id)
1573                && message.region.slice().contains(state.rank)
1574        };
1575
1576        // Bump keepalive (if requested) in a separate mutable pass, so the read
1577        // loop can borrow `&self` via `proc_state` (mirrors `KeepaliveGetState`).
1578        if let Some(expires_after) = message.keepalive {
1579            for state in self.created.values_mut() {
1580                if selects(state) {
1581                    state.expiry_time = Some(expires_after);
1582                }
1583            }
1584        }
1585
1586        // Build a sparse overlay of just the ranks this host owns for the mesh,
1587        // keyed by each proc's *base index within the queried region* — not its
1588        // absolute rank. The caller's `ValueMesh` addresses cells by base rank,
1589        // so on a sliced region (e.g. gpus 2..4 → ranks {2,3,6,7}) the absolute
1590        // rank would land in the wrong cell or out of bounds. The caller's
1591        // accumulator merges these per-host overlays into the full proc-state
1592        // mesh; a host owning no selected procs simply posts nothing.
1593        let mut runs = Vec::new();
1594        for (id, state) in self.created.iter() {
1595            if selects(state) {
1596                let base = message.region.slice().index(state.rank)?;
1597                runs.push((base..(base + 1), self.proc_state_from(id, state).await));
1598            }
1599        }
1600
1601        if !runs.is_empty() {
1602            // Runs are single-rank at distinct ranks; sort so the overlay's
1603            // sorted/non-overlapping normalization invariant holds.
1604            runs.sort_by_key(|(range, _)| range.start);
1605            message.reply.post(cx, ValueOverlay::try_from_runs(runs)?);
1606        }
1607
1608        Ok(())
1609    }
1610}
1611
1612#[async_trait]
1613impl Handler<crate::proc_agent::SelfCheck> for HostAgent {
1614    async fn handle(
1615        &mut self,
1616        cx: &Context<Self>,
1617        _: crate::proc_agent::SelfCheck,
1618    ) -> anyhow::Result<()> {
1619        // Walk procs and tear down any whose owner-supplied keepalive has
1620        // lapsed. Mirrors the proc-agent reaper but at host scope: we
1621        // address the same problem (a controller/client died abruptly)
1622        // for proc-level cleanup so the host doesn't leak children.
1623        let Some(duration) = hyperactor_config::global::get(crate::proc_agent::MESH_ORPHAN_TIMEOUT)
1624        else {
1625            return Ok(());
1626        };
1627        let now = std::time::SystemTime::now();
1628        let timeout = hyperactor_config::global::get(hyperactor::config::PROCESS_EXIT_TIMEOUT);
1629
1630        let expired: Vec<ResourceId> = self
1631            .created
1632            .iter()
1633            .filter_map(|(id, state)| {
1634                let expiry = state.expiry_time?;
1635                if now > expiry { Some(id.clone()) } else { None }
1636            })
1637            .collect();
1638
1639        if !expired.is_empty() {
1640            tracing::info!(
1641                "stopping {} orphaned procs past their keepalive expiry",
1642                expired.len(),
1643            );
1644        }
1645
1646        for id in expired {
1647            if let Some(ProcCreationState {
1648                created: Ok((proc_id, _)),
1649                ..
1650            }) = self.created.get(&id)
1651            {
1652                let proc_id = proc_id.clone();
1653                if let Some(host) = self.host() {
1654                    host.request_stop(cx, &proc_id, timeout, "orphaned").await;
1655                }
1656                // Don't reap repeatedly while teardown is in flight.
1657                if let Some(state) = self.created.get_mut(&id) {
1658                    state.expiry_time = None;
1659                }
1660            }
1661        }
1662
1663        cx.post_after(cx, crate::proc_agent::SelfCheck::default(), duration);
1664        Ok(())
1665    }
1666}
1667
1668#[async_trait]
1669impl Handler<resource::List> for HostAgent {
1670    async fn handle(&mut self, cx: &Context<Self>, list: resource::List) -> anyhow::Result<()> {
1671        list.reply.post(cx, self.created.keys().cloned().collect());
1672        Ok(())
1673    }
1674}
1675
1676#[async_trait]
1677impl Handler<resource::KeepaliveGetState<ProcState>> for HostAgent {
1678    async fn handle(
1679        &mut self,
1680        cx: &Context<Self>,
1681        message: resource::KeepaliveGetState<ProcState>,
1682    ) -> anyhow::Result<()> {
1683        // Record the new expiry so the periodic SelfCheck reaper knows the
1684        // owner is still alive. If the owner stops extending the keepalive
1685        // (e.g. its process dies abruptly), the proc will be reaped past
1686        // `expires_after`.
1687        if let Some(state) = self.created.get_mut(&message.get_state.id) {
1688            state.expiry_time = Some(message.expires_after);
1689        }
1690        <Self as Handler<resource::GetState<ProcState>>>::handle(self, cx, message.get_state).await
1691    }
1692}
1693
1694#[async_trait]
1695impl Handler<resource::StreamState<ProcState>> for HostAgent {
1696    async fn handle(
1697        &mut self,
1698        cx: &Context<Self>,
1699        stream_state: resource::StreamState<ProcState>,
1700    ) -> anyhow::Result<()> {
1701        // One cast delivers a single StreamState per host agent. Stream a state
1702        // for each proc this host owns that belongs to the subscribing mesh.
1703        // TODO: register `subscriber` for ongoing updates.
1704        let mut headers = Flattrs::new();
1705        headers.set(crate::proc_agent::STREAM_STATE_SUBSCRIBER, true);
1706
1707        for (id, proc) in self.created.iter() {
1708            // Skip procs that don't belong to the subscribing proc mesh.
1709            if proc
1710                .proc_mesh_id
1711                .as_ref()
1712                .is_none_or(|mesh| mesh.resource_id() != &stream_state.id)
1713            {
1714                continue;
1715            }
1716
1717            let state = match &proc.created {
1718                Ok((proc_id, mesh_agent)) => {
1719                    let (raw_status, proc_status, bootstrap_command) = match self.host() {
1720                        Some(host) => {
1721                            let (status, proc_status) = host.proc_status(proc_id).await;
1722                            (status, proc_status, host.bootstrap_command())
1723                        }
1724                        None => (resource::Status::Unknown, None, None),
1725                    };
1726                    let status = raw_status.clamp_min(self.min_proc_status());
1727                    resource::State {
1728                        id: id.clone(),
1729                        status,
1730                        state: Some(ProcState {
1731                            proc_id: proc_id.clone(),
1732                            create_rank: proc.rank,
1733                            mesh_agent: mesh_agent.clone(),
1734                            bootstrap_command,
1735                            proc_status,
1736                        }),
1737                        generation: 0,
1738                        timestamp: std::time::SystemTime::now(),
1739                    }
1740                }
1741                Err(e) => resource::State {
1742                    id: id.clone(),
1743                    status: resource::Status::Failed(e.to_string()),
1744                    state: None,
1745                    generation: 0,
1746                    timestamp: std::time::SystemTime::now(),
1747                },
1748            };
1749
1750            stream_state
1751                .subscriber
1752                .post_with_headers(cx, headers.clone(), state);
1753        }
1754        Ok(())
1755    }
1756}
1757
1758/// Push client configuration overrides to this host agent's process.
1759///
1760/// The attrs are installed as `Source::ClientOverride` (lowest explicit
1761/// priority), so the host's own env vars and file config take precedence.
1762/// This message is idempotent — sending the same attrs twice replaces
1763/// the layer wholesale.
1764///
1765/// Request-reply: the reply acts as a barrier confirming the config
1766/// is installed. The fatal-on-failure / best-effort policy is the
1767/// caller's contract, not this message's; for the canonical
1768/// attach-time contract see the HM-* invariants in `host_mesh.rs`.
1769#[derive(
1770    Debug,
1771    Clone,
1772    Named,
1773    Handler,
1774    RefClient,
1775    HandleClient,
1776    Serialize,
1777    Deserialize
1778)]
1779pub struct SetClientConfig {
1780    pub attrs: Attrs,
1781    /// This host's ordinal within the config-push cast region, stamped by the
1782    /// cast layer. Used to position this host's install ack overlay.
1783    pub rank: resource::Rank,
1784    /// Streaming install ack. Each host posts a single-rank overlay at its
1785    /// ordinal once it has installed the config; the caller reduces these into
1786    /// a `StatusMesh` barrier and can name exactly which hosts (if any) never
1787    /// acknowledged (HM-4). `StatusMesh` is used here only as a per-rank
1788    /// presence/ack barrier — the status value itself is not meaningful (see
1789    /// the handler).
1790    pub reply: PortRef<crate::StatusOverlay>,
1791}
1792wirevalue::register_type!(SetClientConfig);
1793
1794#[async_trait]
1795impl Handler<SetClientConfig> for HostAgent {
1796    async fn handle(&mut self, cx: &Context<Self>, msg: SetClientConfig) -> anyhow::Result<()> {
1797        let rank = msg.rank.0.expect("rank should be stamped before delivery");
1798        // Use `set` (not `create_or_merge`) because `push_config` always
1799        // sends a complete `propagatable_attrs()` snapshot. Replacing the
1800        // layer wholesale is intentional and idempotent.
1801        hyperactor_config::global::set(
1802            hyperactor_config::global::Source::ClientOverride,
1803            msg.attrs,
1804        );
1805        tracing::debug!("installed client config override on host agent");
1806        // Ack as a single-rank overlay at this host's ordinal. `StatusMesh` is
1807        // reused here purely as a per-rank presence/ack barrier, not as a
1808        // lifecycle signal: there is no "config installed" status, so we pick
1809        // `Running` only because the barrier just needs any value distinct from
1810        // the `NotExist` seed to mark this host as having acknowledged. A
1811        // purpose-built `ValueMesh<2-state>` would model this more honestly;
1812        // this reuses the already-registered `StatusMesh` reducer instead.
1813        let installed_overlay = crate::StatusOverlay::try_from_runs(vec![(
1814            rank..(rank + 1),
1815            resource::Status::Running,
1816        )])
1817        .expect("valid single-run overlay");
1818
1819        msg.reply.post(cx, installed_overlay);
1820
1821        Ok(())
1822    }
1823}
1824
1825/// Boot the ProcAgent on the host's local proc (LP-1).
1826///
1827/// The local proc starts empty; this message activates it by spawning
1828/// a `ProcAgent` (once, via `OnceLock`). Called by
1829/// `monarch_hyperactor::bootstrap_host` when setting up the Python
1830/// `this_proc()` singleton.
1831///
1832/// See also: `crate::host::LOCAL_PROC_NAME`.
1833#[derive(Debug, hyperactor::Handler, hyperactor::HandleClient)]
1834pub struct GetLocalProc {
1835    #[reply]
1836    pub proc_mesh_agent: PortHandle<ActorHandle<ProcAgent>>,
1837}
1838
1839#[async_trait]
1840impl Handler<GetLocalProc> for HostAgent {
1841    async fn handle(
1842        &mut self,
1843        cx: &Context<Self>,
1844        GetLocalProc { proc_mesh_agent }: GetLocalProc,
1845    ) -> anyhow::Result<()> {
1846        let host = self
1847            .host()
1848            .ok_or_else(|| anyhow::anyhow!("HostAgent has already shut down"))?;
1849        let agent = self
1850            .local_mesh_agent
1851            .get_or_init(|| ProcAgent::boot_v1(host.local_proc().clone(), None));
1852
1853        match agent {
1854            Err(e) => anyhow::bail!("error booting local proc: {}", e),
1855            Ok(agent) => proc_mesh_agent.post(cx, agent.clone()),
1856        };
1857
1858        Ok(())
1859    }
1860}
1861
1862#[async_trait]
1863impl Handler<PySpyDump> for HostAgent {
1864    async fn handle(
1865        &mut self,
1866        cx: &Context<Self>,
1867        message: PySpyDump,
1868    ) -> Result<(), anyhow::Error> {
1869        PySpyWorker::spawn_and_forward(cx, message.opts, message.result)
1870    }
1871}
1872
1873#[async_trait]
1874impl Handler<PySpyProfile> for HostAgent {
1875    async fn handle(
1876        &mut self,
1877        cx: &Context<Self>,
1878        message: PySpyProfile,
1879    ) -> Result<(), anyhow::Error> {
1880        PySpyProfileWorker::spawn_and_forward(cx, message.request, message.result)
1881    }
1882}
1883
1884#[async_trait]
1885impl Handler<ConfigDump> for HostAgent {
1886    async fn handle(
1887        &mut self,
1888        cx: &Context<Self>,
1889        message: ConfigDump,
1890    ) -> Result<(), anyhow::Error> {
1891        let entries = hyperactor_config::global::config_entries();
1892        message.result.post(cx, ConfigDumpResult { entries });
1893        Ok(())
1894    }
1895}
1896
1897#[cfg(all(test, fbcode_build))]
1898mod tests {
1899    use std::assert_matches;
1900
1901    use hyperactor::ActorAddr;
1902    use hyperactor::Proc;
1903    use hyperactor::channel::ChannelTransport;
1904    use hyperactor::id::Label;
1905    use hyperactor::id::Uid;
1906
1907    use super::*;
1908    use crate::bootstrap::ProcStatus;
1909    use crate::mesh_id::ResourceId;
1910    use crate::resource::CreateOrUpdateClient;
1911    use crate::resource::GetStateClient;
1912    use crate::resource::WaitRankStatusClient;
1913
1914    #[tokio::test]
1915    async fn test_basic() {
1916        let host = Host::new(
1917            BootstrapProcManager::new(BootstrapCommand::test()).unwrap(),
1918            ChannelTransport::Unix.any(),
1919        )
1920        .await
1921        .unwrap();
1922
1923        let host_addr = host.addr().clone();
1924        let system_proc = host.system_proc().clone();
1925        let host_agent = system_proc
1926            .spawn_with_uid(
1927                Uid::singleton(Label::new(HOST_MESH_AGENT_ACTOR_NAME).unwrap()),
1928                HostAgent::new_process(host, None),
1929            )
1930            .unwrap();
1931        HostAgent::wait_initialized(&host_agent).await.unwrap();
1932
1933        let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
1934        let client = client_proc.client("client");
1935
1936        let id = ResourceId::instance(Label::new("proc1").unwrap());
1937
1938        // First, create the proc, then query its state:
1939
1940        host_agent
1941            .create_or_update(
1942                &client,
1943                id.clone(),
1944                resource::Rank::new(0),
1945                ProcSpec::default(),
1946            )
1947            .await
1948            .unwrap();
1949        // The host advertises spawned procs with a
1950        // `Via(proc_uid, Addr(host_addr))` location so its gateway can
1951        // peel and forward to the child's serving address. Construct
1952        // the expected proc_addr the same way.
1953        let expected_location =
1954            hyperactor::Location::from(host_addr.clone()).with_via(id.uid().clone());
1955        let expected_proc_addr = ProcAddr::new(id.proc_id(), expected_location);
1956        assert_matches!(
1957            host_agent.get_state(&client, id.clone()).await.unwrap(),
1958            resource::State {
1959                id: resource_id,
1960                status: resource::Status::Running,
1961                state: Some(ProcState {
1962                    // The proc itself should be direct addressed, with its name directly.
1963                    proc_id,
1964                    // The mesh agent should run in the same proc, under the name
1965                    // "proc_agent".
1966                    mesh_agent,
1967                    bootstrap_command,
1968                    proc_status: Some(ProcStatus::Ready { started_at: _, addr: _, agent: proc_status_mesh_agent}),
1969                    ..
1970                }),
1971                ..
1972            } if id == resource_id
1973              && proc_id == expected_proc_addr
1974              && mesh_agent == ActorRef::attest(expected_proc_addr.actor_addr(crate::proc_agent::PROC_AGENT_ACTOR_NAME))
1975              && bootstrap_command == Some(BootstrapCommand::test())
1976              && mesh_agent == proc_status_mesh_agent
1977        );
1978    }
1979
1980    /// WaitRankStatus on a running proc replies immediately with Running.
1981    #[tokio::test]
1982    async fn test_wait_rank_status_already_running() {
1983        let host = Host::new(
1984            BootstrapProcManager::new(BootstrapCommand::test()).unwrap(),
1985            ChannelTransport::Unix.any(),
1986        )
1987        .await
1988        .unwrap();
1989
1990        let system_proc = host.system_proc().clone();
1991        let host_agent = system_proc
1992            .spawn_with_uid(
1993                Uid::singleton(Label::new(HOST_MESH_AGENT_ACTOR_NAME).unwrap()),
1994                HostAgent::new_process(host, None),
1995            )
1996            .unwrap();
1997        HostAgent::wait_initialized(&host_agent).await.unwrap();
1998
1999        let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
2000        let client = client_proc.client("client");
2001
2002        let id = ResourceId::instance(Label::new("proc1").unwrap());
2003        host_agent
2004            .create_or_update(
2005                &client,
2006                id.clone(),
2007                resource::Rank::new(0),
2008                ProcSpec::default(),
2009            )
2010            .await
2011            .unwrap();
2012
2013        // Proc is Running; wait for Running should reply immediately.
2014        let (port, mut rx) = client.open_port::<crate::StatusOverlay>();
2015        host_agent
2016            .wait_rank_status(&client, id, resource::Status::Running, port.bind())
2017            .await
2018            .unwrap();
2019
2020        let overlay = tokio::time::timeout(Duration::from_secs(30), rx.recv())
2021            .await
2022            .expect("reply timed out")
2023            .expect("reply channel closed");
2024        assert!(!overlay.is_empty(), "expected non-empty overlay");
2025    }
2026
2027    /// WaitRankStatus for Stopped, then stop the proc — reply should
2028    /// arrive only after the proc actually stops.
2029    #[tokio::test]
2030    async fn test_wait_rank_status_stop() {
2031        let host = Host::new(
2032            BootstrapProcManager::new(BootstrapCommand::test()).unwrap(),
2033            ChannelTransport::Unix.any(),
2034        )
2035        .await
2036        .unwrap();
2037
2038        let system_proc = host.system_proc().clone();
2039        let host_agent = system_proc
2040            .spawn_with_uid(
2041                Uid::singleton(Label::new(HOST_MESH_AGENT_ACTOR_NAME).unwrap()),
2042                HostAgent::new_process(host, None),
2043            )
2044            .unwrap();
2045        HostAgent::wait_initialized(&host_agent).await.unwrap();
2046
2047        let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
2048        let client = client_proc.client("client");
2049
2050        let id = ResourceId::instance(Label::new("proc1").unwrap());
2051        host_agent
2052            .create_or_update(
2053                &client,
2054                id.clone(),
2055                resource::Rank::new(0),
2056                ProcSpec::default(),
2057            )
2058            .await
2059            .unwrap();
2060
2061        // Wait for Stopped — should not reply yet.
2062        let (port, mut rx) = client.open_port::<crate::StatusOverlay>();
2063        host_agent
2064            .wait_rank_status(&client, id.clone(), resource::Status::Stopped, port.bind())
2065            .await
2066            .unwrap();
2067
2068        // Stop the proc.
2069        crate::resource::StopClient::stop(&host_agent, &client, id, "test".to_string())
2070            .await
2071            .unwrap();
2072
2073        // Now the reply should arrive.
2074        let overlay = tokio::time::timeout(Duration::from_secs(30), rx.recv())
2075            .await
2076            .expect("reply timed out — proc did not reach Stopped")
2077            .expect("reply channel closed");
2078        assert!(!overlay.is_empty(), "expected non-empty overlay");
2079    }
2080
2081    /// WaitRankStatus sent before the proc is created — the waiter is
2082    /// stashed and replied to once CreateOrUpdate runs.
2083    #[tokio::test]
2084    async fn test_wait_rank_status_before_proc_exists() {
2085        let host = Host::new(
2086            BootstrapProcManager::new(BootstrapCommand::test()).unwrap(),
2087            ChannelTransport::Unix.any(),
2088        )
2089        .await
2090        .unwrap();
2091
2092        let system_proc = host.system_proc().clone();
2093        let host_agent = system_proc
2094            .spawn_with_uid(
2095                Uid::singleton(Label::new(HOST_MESH_AGENT_ACTOR_NAME).unwrap()),
2096                HostAgent::new_process(host, None),
2097            )
2098            .unwrap();
2099        HostAgent::wait_initialized(&host_agent).await.unwrap();
2100
2101        let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
2102        let client = client_proc.client("client");
2103
2104        let id = ResourceId::instance(Label::new("proc1").unwrap());
2105
2106        // Wait for Running on a proc that doesn't exist yet.
2107        let (port, mut rx) = client.open_port::<crate::StatusOverlay>();
2108        host_agent
2109            .wait_rank_status(&client, id.clone(), resource::Status::Running, port.bind())
2110            .await
2111            .unwrap();
2112
2113        // Now create the proc — the stashed waiter should get its
2114        // sentinel rank fixed and be flushed once the proc is Running.
2115        host_agent
2116            .create_or_update(&client, id, resource::Rank::new(0), ProcSpec::default())
2117            .await
2118            .unwrap();
2119
2120        let overlay = tokio::time::timeout(Duration::from_secs(30), rx.recv())
2121            .await
2122            .expect("reply timed out — waiter was not flushed after CreateOrUpdate")
2123            .expect("reply channel closed");
2124        assert!(!overlay.is_empty(), "expected non-empty overlay");
2125    }
2126
2127    /// DrainHost with a host_mesh_id filter only stops procs
2128    /// belonging to that mesh; procs from other meshes are unaffected.
2129    #[tokio::test]
2130    async fn test_drain_scoped_to_host_mesh_id() {
2131        let host = Host::new(
2132            BootstrapProcManager::new(BootstrapCommand::test()).unwrap(),
2133            ChannelTransport::Unix.any(),
2134        )
2135        .await
2136        .unwrap();
2137
2138        let system_proc = host.system_proc().clone();
2139        let host_agent = system_proc
2140            .spawn_with_uid(
2141                Uid::singleton(Label::new(HOST_MESH_AGENT_ACTOR_NAME).unwrap()),
2142                HostAgent::new_process(host, None),
2143            )
2144            .unwrap();
2145        HostAgent::wait_initialized(&host_agent).await.unwrap();
2146
2147        let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
2148        let client = client_proc.client("client");
2149
2150        let mesh_a = HostMeshId::instance(Label::new("mesh-a").unwrap());
2151        let mesh_b = HostMeshId::instance(Label::new("mesh-b").unwrap());
2152        let proc_a_id = ResourceId::instance(Label::new("proc-a").unwrap());
2153        let proc_b_id = ResourceId::instance(Label::new("proc-b").unwrap());
2154
2155        // Create proc_a belonging to mesh_a.
2156        let spec_a = ProcSpec {
2157            host_mesh_id: Some(mesh_a.clone()),
2158            ..Default::default()
2159        };
2160        host_agent
2161            .create_or_update(&client, proc_a_id.clone(), resource::Rank::new(0), spec_a)
2162            .await
2163            .unwrap();
2164
2165        // Create proc_b belonging to mesh_b.
2166        let spec_b = ProcSpec {
2167            host_mesh_id: Some(mesh_b.clone()),
2168            ..Default::default()
2169        };
2170        host_agent
2171            .create_or_update(&client, proc_b_id.clone(), resource::Rank::new(1), spec_b)
2172            .await
2173            .unwrap();
2174
2175        // Both should be Running.
2176        assert_matches!(
2177            host_agent
2178                .get_state(&client, proc_a_id.clone())
2179                .await
2180                .unwrap(),
2181            resource::State {
2182                status: resource::Status::Running,
2183                ..
2184            }
2185        );
2186        assert_matches!(
2187            host_agent
2188                .get_state(&client, proc_b_id.clone())
2189                .await
2190                .unwrap(),
2191            resource::State {
2192                status: resource::Status::Running,
2193                ..
2194            }
2195        );
2196
2197        // Drain only mesh_a.
2198        let (drain_reply, mut drain_rx) = client.open_port::<crate::StatusOverlay>();
2199        host_agent
2200            .drain_host(
2201                &client,
2202                Duration::from_secs(5),
2203                16,
2204                Some(mesh_a.clone()),
2205                resource::Rank::new(0),
2206                drain_reply.bind(),
2207            )
2208            .await
2209            .unwrap();
2210        // Wait for the host to report drained before asserting.
2211        drain_rx.recv().await.unwrap();
2212
2213        // proc_a should be gone (removed from created).
2214        assert_matches!(
2215            host_agent
2216                .get_state(&client, proc_a_id.clone())
2217                .await
2218                .unwrap(),
2219            resource::State {
2220                status: resource::Status::NotExist,
2221                ..
2222            }
2223        );
2224
2225        // proc_b should still be Running.
2226        assert_matches!(
2227            host_agent
2228                .get_state(&client, proc_b_id.clone())
2229                .await
2230                .unwrap(),
2231            resource::State {
2232                status: resource::Status::Running,
2233                ..
2234            }
2235        );
2236    }
2237
2238    /// DrainHost with host_mesh_id=None drains all procs regardless
2239    /// of their mesh affiliation (backwards compatibility).
2240    #[tokio::test]
2241    async fn test_drain_none_drains_all() {
2242        let host = Host::new(
2243            BootstrapProcManager::new(BootstrapCommand::test()).unwrap(),
2244            ChannelTransport::Unix.any(),
2245        )
2246        .await
2247        .unwrap();
2248
2249        let system_proc = host.system_proc().clone();
2250        let host_agent = system_proc
2251            .spawn_with_uid(
2252                Uid::singleton(Label::new(HOST_MESH_AGENT_ACTOR_NAME).unwrap()),
2253                HostAgent::new_process(host, None),
2254            )
2255            .unwrap();
2256        HostAgent::wait_initialized(&host_agent).await.unwrap();
2257
2258        let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
2259        let client = client_proc.client("client");
2260
2261        let mesh_a = HostMeshId::instance(Label::new("mesh-a").unwrap());
2262        let mesh_b = HostMeshId::instance(Label::new("mesh-b").unwrap());
2263        let proc_a_id = ResourceId::instance(Label::new("proc-a").unwrap());
2264        let proc_b_id = ResourceId::instance(Label::new("proc-b").unwrap());
2265
2266        let spec_a = ProcSpec {
2267            host_mesh_id: Some(mesh_a),
2268            ..Default::default()
2269        };
2270        host_agent
2271            .create_or_update(&client, proc_a_id.clone(), resource::Rank::new(0), spec_a)
2272            .await
2273            .unwrap();
2274
2275        let spec_b = ProcSpec {
2276            host_mesh_id: Some(mesh_b),
2277            ..Default::default()
2278        };
2279        host_agent
2280            .create_or_update(&client, proc_b_id.clone(), resource::Rank::new(1), spec_b)
2281            .await
2282            .unwrap();
2283
2284        // Drain all (no filter).
2285        let (drain_reply, mut drain_rx) = client.open_port::<crate::StatusOverlay>();
2286        host_agent
2287            .drain_host(
2288                &client,
2289                Duration::from_secs(5),
2290                16,
2291                None,
2292                resource::Rank::new(0),
2293                drain_reply.bind(),
2294            )
2295            .await
2296            .unwrap();
2297        // Wait for the host to report drained before asserting.
2298        drain_rx.recv().await.unwrap();
2299
2300        // Both should be gone.
2301        assert_matches!(
2302            host_agent.get_state(&client, proc_a_id).await.unwrap(),
2303            resource::State {
2304                status: resource::Status::NotExist,
2305                ..
2306            }
2307        );
2308        assert_matches!(
2309            host_agent.get_state(&client, proc_b_id).await.unwrap(),
2310            resource::State {
2311                status: resource::Status::NotExist,
2312                ..
2313            }
2314        );
2315    }
2316
2317    // PD-6/PD-8 regression: QueryChild(Proc) on the service proc
2318    // returns non-zero queue stats after the host_agent has handled
2319    // messages. Guards against the bug where the HostAgent closure
2320    // defaulted queue stats to zero because it predated Proc-level
2321    // queue accessors.
2322    #[tokio::test]
2323    async fn test_service_proc_query_child_has_queue_stats() {
2324        use hyperactor::introspect::IntrospectMessage;
2325        use hyperactor::introspect::IntrospectResult;
2326
2327        let host = Host::new(
2328            BootstrapProcManager::new(BootstrapCommand::test()).unwrap(),
2329            ChannelTransport::Unix.any(),
2330        )
2331        .await
2332        .unwrap();
2333
2334        let system_proc = host.system_proc().clone();
2335        let host_agent = system_proc
2336            .spawn_with_uid(
2337                Uid::singleton(Label::new(HOST_MESH_AGENT_ACTOR_NAME).unwrap()),
2338                HostAgent::new_process(host, None),
2339            )
2340            .unwrap();
2341        HostAgent::wait_initialized(&host_agent).await.unwrap();
2342
2343        let client_proc =
2344            Proc::direct(ChannelTransport::Unix.any(), "qd_client".to_string()).unwrap();
2345        let client = client_proc.client("client");
2346
2347        // Spawn a proc so the host_agent processes at least one
2348        // CreateOrUpdate message, which goes through the work queue.
2349        let name = ResourceId::instance(Label::new("qd_test_proc").unwrap());
2350        host_agent
2351            .create_or_update(
2352                &client,
2353                name.clone(),
2354                resource::Rank::new(0),
2355                ProcSpec::default(),
2356            )
2357            .await
2358            .unwrap();
2359
2360        // The host_agent has now processed messages on the service
2361        // proc. Query the service proc's introspection.
2362        let agent_ref = system_proc
2363            .proc_addr()
2364            .actor_addr(HOST_MESH_AGENT_ACTOR_NAME);
2365        let agent_id: ActorAddr = agent_ref;
2366        let port = agent_id.introspect_port();
2367
2368        // Poll until we see non-zero watermark (evidence of queue
2369        // traffic since startup).
2370        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
2371        loop {
2372            let (reply_port, reply_rx) = client.open_once_port::<IntrospectResult>();
2373            port.post(
2374                &client,
2375                IntrospectMessage::QueryChild {
2376                    child_ref: Addr::Proc(system_proc.proc_addr().clone()),
2377                    reply: reply_port.bind(),
2378                },
2379            );
2380            let payload = tokio::time::timeout(std::time::Duration::from_secs(5), reply_rx.recv())
2381                .await
2382                .expect("QueryChild timed out")
2383                .expect("reply channel closed");
2384
2385            let attrs: hyperactor_config::Attrs =
2386                serde_json::from_str(&payload.attrs).expect("valid attrs JSON");
2387
2388            let hwm = attrs
2389                .get(crate::introspect::ACTOR_WORK_QUEUE_DEPTH_HIGH_WATER_MARK)
2390                .copied()
2391                .unwrap_or(0);
2392            let last_nonzero: Option<u64> = attrs
2393                .get(crate::introspect::LAST_NONZERO_QUEUE_DEPTH_AGE_MS)
2394                .copied()
2395                .flatten();
2396
2397            if hwm > 0 {
2398                // The service proc's watermark should reflect
2399                // the messages the host_agent processed.
2400                assert!(
2401                    last_nonzero.is_some(),
2402                    "last-nonzero should be Some when watermark is {hwm}",
2403                );
2404                break;
2405            }
2406
2407            assert!(
2408                tokio::time::Instant::now() < deadline,
2409                "timed out waiting for service proc watermark > 0",
2410            );
2411            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2412        }
2413    }
2414
2415    /// A single `SpawnProcs` message at host rank 0 fans out into
2416    /// `num_per_host` procs, each created under the id derived from
2417    /// `proc_name(&proc_mesh_id, rank)`. All of them should come up Running.
2418    #[tokio::test]
2419    async fn test_spawn_procs_many_per_host() {
2420        let host = Host::new(
2421            BootstrapProcManager::new(BootstrapCommand::test()).unwrap(),
2422            ChannelTransport::Unix.any(),
2423        )
2424        .await
2425        .unwrap();
2426
2427        let system_proc = host.system_proc().clone();
2428        let host_agent = system_proc
2429            .spawn_with_uid(
2430                Uid::singleton(Label::new(HOST_MESH_AGENT_ACTOR_NAME).unwrap()),
2431                HostAgent::new(HostAgentMode::Process {
2432                    host,
2433                    shutdown_tx: None,
2434                }),
2435            )
2436            .unwrap();
2437
2438        let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
2439        let client = client_proc.client("client");
2440
2441        let proc_mesh_id = ProcMeshId::singleton(Label::new("spawn-many").unwrap());
2442        let num_per_host = 4;
2443
2444        // Send a single point-to-point SpawnProcs (not a cast) to the host
2445        // agent at host rank 0.
2446        let agent_ref: ActorRef<HostAgent> = host_agent.bind();
2447        agent_ref.post(
2448            &client,
2449            SpawnProcs {
2450                rank: resource::Rank::new(0),
2451                proc_mesh_id: proc_mesh_id.clone(),
2452                num_per_host,
2453                client_config_override: Attrs::new(),
2454                host_mesh_id: None,
2455                default_bootstrap_command: None,
2456                proc_bind: None,
2457                bootstrap_commands: None,
2458                status_reply: None,
2459            },
2460        );
2461
2462        // Each of the num_per_host procs should reach Running.
2463        for rank in 0..num_per_host {
2464            let id = proc_name(&proc_mesh_id, rank);
2465            let (port, mut rx) = client.open_port::<crate::StatusOverlay>();
2466            host_agent
2467                .wait_rank_status(&client, id.clone(), resource::Status::Running, port.bind())
2468                .await
2469                .unwrap();
2470            let overlay = tokio::time::timeout(Duration::from_secs(30), rx.recv())
2471                .await
2472                .unwrap_or_else(|_| panic!("proc {rank} did not reach Running"))
2473                .expect("reply channel closed");
2474            assert!(
2475                !overlay.is_empty(),
2476                "expected non-empty Running overlay for proc {rank}",
2477            );
2478
2479            assert_matches!(
2480                host_agent.get_state(&client, id).await.unwrap(),
2481                resource::State {
2482                    status: resource::Status::Running,
2483                    ..
2484                }
2485            );
2486        }
2487    }
2488}