Skip to main content

net/adapter/net/compute/
daemon.rs

1//! MeshDaemon trait and supporting types.
2//!
3//! A daemon is a stateful or stateless event processor that runs on the mesh.
4//! It consumes causal events and produces output events. The runtime handles
5//! chain building, horizon tracking, and snapshot packaging.
6
7use bytes::Bytes;
8
9use crate::adapter::net::behavior::capability::{CapabilityFilter, CapabilitySet};
10use crate::adapter::net::state::causal::CausalEvent;
11
12/// A daemon that runs on the mesh.
13///
14/// Daemons consume inbound causal events via `process()` and return zero or
15/// more output payloads. The runtime wraps outputs in `CausalLink`s
16/// automatically — the daemon only produces raw payloads.
17///
18/// # Performance
19///
20/// `process()` must complete in microseconds. Heavy work should be deferred
21/// to a background task and emitted as a later event.
22///
23/// # WASM compatibility
24///
25/// All methods are synchronous — no async. Input/output are `Bytes` — maps
26/// cleanly to WASM linear memory. No generics or associated types.
27pub trait MeshDaemon: Send + Sync {
28    /// Human-readable name (for logging, placement ads).
29    fn name(&self) -> &str;
30
31    /// Capability requirements for placement.
32    ///
33    /// The scheduler uses this to find nodes whose `CapabilitySet` matches.
34    /// Return `CapabilityFilter::default()` to run anywhere.
35    fn requirements(&self) -> CapabilityFilter;
36
37    /// Hard capability requirements for Phase F-aware placement.
38    ///
39    /// Returns the set of tags + metadata the candidate node MUST
40    /// have for the daemon to run there. Tag-set inclusion is the
41    /// hard-constraint check (`StandardPlacement` returns `None`
42    /// when a required tag is absent — see
43    /// [`crate::adapter::net::behavior::placement`]).
44    ///
45    /// Default: empty set. Daemons that care about specific
46    /// hardware / software override:
47    ///
48    /// ```ignore
49    /// fn required_capabilities(&self) -> CapabilitySet {
50    ///     CapabilitySet::new().add_tag("hardware.gpu")
51    /// }
52    /// ```
53    ///
54    /// Phase G slice 2 of `CAPABILITY_SYSTEM_PLAN.md`. Coexists
55    /// with the legacy `requirements()` method until the
56    /// `mikoshi-placement-v2` feature flag flips: `requirements()`
57    /// drives the legacy `CapabilityFilter`-based path; this
58    /// method drives the `Artifact::Daemon { required, .. }`
59    /// payload that `PlacementFilter` impls consume.
60    fn required_capabilities(&self) -> CapabilitySet {
61        CapabilitySet::default()
62    }
63
64    /// Soft capability preferences for Phase F-aware placement.
65    ///
66    /// Returns the set of tags + metadata the daemon prefers but
67    /// does NOT require. The scheduler factors satisfaction of
68    /// these into per-axis scoring; missing optional capabilities
69    /// don't veto placement (unlike `required_capabilities`).
70    ///
71    /// Default: empty set. Daemons with a strict required floor
72    /// but additional preferences (e.g. "must have GPU; prefer
73    /// 80GB+ VRAM") populate this via per-tag adds.
74    ///
75    /// Phase G slice 2 of `CAPABILITY_SYSTEM_PLAN.md`. Slice 5's
76    /// per-axis scorers consume the optional set when scoring
77    /// candidates; slice 2's stub axes return `1.0` regardless.
78    fn optional_capabilities(&self) -> CapabilitySet {
79        CapabilitySet::default()
80    }
81
82    /// Process one inbound causal event, returning zero or more output payloads.
83    ///
84    /// The output `Bytes` values become payloads in the daemon's own causal
85    /// chain (the runtime wraps them in CausalLinks automatically).
86    fn process(&mut self, event: &CausalEvent) -> Result<Vec<Bytes>, DaemonError>;
87
88    /// Serialize current state for migration/checkpoint.
89    ///
90    /// Returns `None` for stateless daemons. Stateful daemons must return
91    /// opaque bytes that `restore()` can accept.
92    fn snapshot(&self) -> Option<Bytes> {
93        None
94    }
95
96    /// Whether this daemon carries persistent state that
97    /// migration / restart paths must preserve.
98    ///
99    /// The default `restore` previously accepted any bytes silently
100    /// for daemons that didn't override it, including ones that
101    /// *should* have been stateful but forgot to provide a `restore`
102    /// impl. The new default restores correctly: it matches
103    /// `is_stateful()`'s answer. Stateless daemons leave
104    /// `is_stateful` at `false` (matches `snapshot() = None`);
105    /// stateful daemons override `is_stateful` to `true` AND
106    /// `snapshot` / `restore`.
107    ///
108    /// The migration path can use this to refuse to migrate a
109    /// stateful daemon's snapshot bytes into a stateless target,
110    /// surfacing the misconfiguration rather than silently
111    /// dropping state.
112    fn is_stateful(&self) -> bool {
113        false
114    }
115
116    /// Restore from a previous snapshot.
117    ///
118    /// Called before any `process()` calls after migration.
119    ///
120    /// The default implementation now refuses non-empty state on
121    /// stateless daemons (`is_stateful() == false`) — silently
122    /// discarding a stateful source's snapshot into a stateless
123    /// target loses every byte of state with no signal. Stateful
124    /// daemons must override both `is_stateful` and `restore`. An
125    /// empty `state` is still accepted (it's what
126    /// `snapshot() -> None` produces under the migration adapter),
127    /// so genuine stateless-to-stateless migrations
128    /// continue to work.
129    fn restore(&mut self, state: Bytes) -> Result<(), DaemonError> {
130        if !self.is_stateful() && !state.is_empty() {
131            return Err(DaemonError::RestoreFailed(format!(
132                "stateless daemon (is_stateful=false) cannot restore \
133                 {}-byte snapshot — override is_stateful() + restore() \
134                 if this daemon is actually stateful",
135                state.len()
136            )));
137        }
138        Ok(())
139    }
140
141    /// Self-reported health. Polled by the MeshOS supervisor on
142    /// each tick. Default `Healthy`.
143    ///
144    /// Daemons with a real health surface (queue-depth probes,
145    /// internal cache freshness, dependency readiness, etc.)
146    /// override to return a richer value. The supervisor surfaces
147    /// the latest sample on the behavior snapshot for Deck.
148    ///
149    /// Must complete in microseconds — same constraint as
150    /// `process()`. Heavy probes belong in a side task whose
151    /// result the daemon caches.
152    fn health(&self) -> DaemonHealth {
153        DaemonHealth::Healthy
154    }
155
156    /// Self-reported saturation, `0.0` (idle) to `1.0` (fully
157    /// loaded). Used by Phase D-1's mesh scheduler to decide
158    /// whether a daemon's host is a good candidate for new
159    /// work. Default `0.0`.
160    ///
161    /// Daemons without a meaningful saturation surface should
162    /// leave the default. The value is informational under the
163    /// current scheduler; a poor estimate doesn't cause
164    /// migrations to thrash.
165    fn saturation(&self) -> f32 {
166        0.0
167    }
168
169    /// Receive a control event from the supervisor. Default:
170    /// no-op (the daemon proceeds as normal regardless of the
171    /// control signal).
172    ///
173    /// Daemons that participate in graceful shutdown / drain /
174    /// backpressure override to react. The dispatch is sync —
175    /// the supervisor calls this between `process()` events on
176    /// the daemon's main task, so long-running work in
177    /// `on_control` blocks subsequent event processing.
178    fn on_control(&mut self, _event: DaemonControl) {}
179}
180
181/// Self-reported daemon health. Default trait impl returns
182/// `Healthy`; daemons with a real health surface override
183/// `MeshDaemon::health` to return a richer value.
184///
185/// Compiled into the substrate (not gated on the `meshos`
186/// feature) so daemons that compile against `MeshDaemon` can
187/// return this type without conditional compilation.
188#[derive(Clone, Debug, Eq, PartialEq)]
189#[non_exhaustive]
190pub enum DaemonHealth {
191    /// Daemon is fully operational.
192    Healthy,
193    /// Daemon is running but degraded. `reason` rides into the
194    /// behavior snapshot's recent-failures ring buffer + Deck
195    /// render.
196    Degraded {
197        /// Operator-readable reason.
198        reason: String,
199    },
200    /// Daemon is non-functional but hasn't crashed. The
201    /// supervisor records this and may emit a `StopDaemon`
202    /// action if the desired-state intent flips to `Stop`.
203    Unhealthy,
204}
205
206/// Supervisor → daemon control event. Delivered via
207/// `MeshDaemon::on_control`. Carries relative-duration
208/// deadlines (no `Instant`) so a daemon running under any
209/// clock source can react.
210///
211/// The MeshOS-side richer form `MeshOsControl` carries
212/// `Instant` deadlines for SDK scheduling; the supervisor
213/// integration layer converts via `MeshOsControl::to_daemon_control(now)`.
214///
215/// `#[non_exhaustive]` so later phases add control variants
216/// without breaking daemon implementations.
217#[derive(Clone, Debug, PartialEq)]
218#[non_exhaustive]
219pub enum DaemonControl {
220    /// Graceful shutdown. The daemon should finish in-flight
221    /// work and exit before `grace_period_ms` elapses. Past the
222    /// deadline the supervisor force-terminates.
223    Shutdown {
224        /// Milliseconds the daemon has before the supervisor
225        /// force-terminates.
226        grace_period_ms: u64,
227    },
228
229    /// Drain start. Stop accepting new work; in-flight work
230    /// continues until `grace_period_ms` elapses or `DrainFinish`
231    /// arrives.
232    DrainStart {
233        /// Milliseconds the drain has before forced cutoff.
234        grace_period_ms: u64,
235    },
236
237    /// Drain done. The daemon should exit immediately;
238    /// in-flight work may be abandoned.
239    DrainFinish,
240
241    /// Cluster-wide backpressure is asserted. The daemon should
242    /// reduce optional work (cache warmup, background indexing,
243    /// etc.) proportional to `level ∈ [0.0, 1.0]`. 1.0 means
244    /// "pause optional work entirely".
245    BackpressureOn {
246        /// Severity in `[0.0, 1.0]`. 0 means just-barely
247        /// triggered, 1 means catastrophic queue depth.
248        level: f32,
249    },
250
251    /// Cluster-wide backpressure cleared. Resume normal work.
252    BackpressureOff,
253}
254
255/// Lifecycle event a [`DaemonLifecycleObserver`] receives when
256/// a daemon's state on this node changes. Plain-data (no
257/// references) so observers can buffer / async-forward without
258/// lifetime issues.
259///
260/// The integration with MeshOS lives in `behavior::meshos::sources` —
261/// a `MeshOsDaemonLifecycleSink` impls this trait and translates
262/// each event to the matching `MeshOsEvent::DaemonLifecycle`.
263#[derive(Clone, Debug)]
264#[non_exhaustive]
265pub enum DaemonLifecycleEvent {
266    /// Daemon registered on this node.
267    Registered {
268        /// `MeshDaemon::origin_hash`.
269        id: u64,
270        /// `MeshDaemon::name`.
271        name: String,
272        /// Monotonic timestamp of the registration.
273        at: std::time::Instant,
274    },
275    /// Daemon unregistered (either via cleanup or migration
276    /// source-side teardown).
277    Unregistered {
278        /// `MeshDaemon::origin_hash`.
279        id: u64,
280        /// Last known name (carried so observers don't need to
281        /// look it up post-unregister).
282        name: String,
283        /// Monotonic timestamp of the unregistration.
284        at: std::time::Instant,
285    },
286    /// Daemon crashed during `process()`.
287    Crashed {
288        /// `MeshDaemon::origin_hash`.
289        id: u64,
290        /// `MeshDaemon::name`.
291        name: String,
292        /// Monotonic timestamp of the crash.
293        at: std::time::Instant,
294        /// Operator-readable reason from the daemon-side error.
295        reason: String,
296    },
297    /// Daemon's self-reported health changed (poller observed a
298    /// transition from the previous sample).
299    HealthChanged {
300        /// `MeshDaemon::origin_hash`.
301        id: u64,
302        /// `MeshDaemon::name`.
303        name: String,
304        /// Monotonic timestamp of the observation.
305        at: std::time::Instant,
306        /// New health value.
307        health: DaemonHealth,
308    },
309    /// Daemon's self-reported saturation changed (poller
310    /// observed a transition exceeding the configured noise
311    /// floor — see `behavior::meshos` for the threshold).
312    SaturationChanged {
313        /// `MeshDaemon::origin_hash`.
314        id: u64,
315        /// `MeshDaemon::name`.
316        name: String,
317        /// Monotonic timestamp of the observation.
318        at: std::time::Instant,
319        /// New saturation value, `[0.0, 1.0]`.
320        saturation: f32,
321    },
322}
323
324/// Observer hook for daemon lifecycle events. Implementations
325/// fan the events out to whichever consumer wants them — the
326/// MeshOS event loop being the canonical near-term consumer.
327///
328/// Methods are sync + non-blocking: observers must not block in
329/// `observe`. The `DaemonRegistry` calls `observe` while
330/// holding (briefly) per-call references; a slow observer
331/// would stall every other lifecycle path.
332pub trait DaemonLifecycleObserver: Send + Sync + 'static {
333    /// Receive one lifecycle event. Must not block.
334    fn observe(&self, event: DaemonLifecycleEvent);
335}
336
337/// Errors from daemon operations.
338#[derive(Debug, Clone, PartialEq, Eq)]
339pub enum DaemonError {
340    /// Daemon processing logic failed.
341    ProcessFailed(String),
342    /// Snapshot serialization failed.
343    SnapshotFailed(String),
344    /// Restore from snapshot failed.
345    RestoreFailed(String),
346    /// Daemon not found in registry.
347    NotFound(u64),
348    /// The daemon at this origin_hash was concurrently swapped
349    /// (`replace`d) or `unregister`ed while this caller was
350    /// preparing to mutate it. The caller's mutation did not
351    /// land — the registry detected the orphaned `Arc` after
352    /// acquiring the inner lock and bailed before invoking the
353    /// host. Retry the operation against the current
354    /// registered host (if any).
355    Stale(u64),
356}
357
358impl std::fmt::Display for DaemonError {
359    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
360        match self {
361            Self::ProcessFailed(msg) => write!(f, "daemon process failed: {}", msg),
362            Self::SnapshotFailed(msg) => write!(f, "snapshot failed: {}", msg),
363            Self::RestoreFailed(msg) => write!(f, "restore failed: {}", msg),
364            Self::NotFound(id) => write!(f, "daemon not found: {:#x}", id),
365            Self::Stale(id) => write!(
366                f,
367                "daemon {:#x} was swapped or unregistered concurrently; mutation did not land",
368                id
369            ),
370        }
371    }
372}
373
374impl std::error::Error for DaemonError {}
375
376/// Configuration for a daemon host.
377#[derive(Debug, Clone)]
378pub struct DaemonHostConfig {
379    /// How often to auto-snapshot (in events processed). 0 = manual only.
380    pub auto_snapshot_interval: u64,
381    /// Maximum events to buffer before forcing a snapshot.
382    pub max_log_entries: u32,
383}
384
385impl Default for DaemonHostConfig {
386    fn default() -> Self {
387        Self {
388            auto_snapshot_interval: 0,
389            max_log_entries: 10_000,
390        }
391    }
392}
393
394/// Runtime statistics for a daemon.
395#[derive(Debug, Clone, Default)]
396pub struct DaemonStats {
397    /// Total events processed.
398    pub events_processed: u64,
399    /// Total output events emitted.
400    pub events_emitted: u64,
401    /// Total processing errors.
402    pub errors: u64,
403    /// Number of snapshots taken.
404    pub snapshots_taken: u64,
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    /// Minimal daemon that doesn't override the new
412    /// `required_capabilities` / `optional_capabilities` methods.
413    /// Pins backward compatibility: existing daemon impls that
414    /// were written pre-Phase-G compile + run unchanged.
415    struct BareDaemon;
416
417    impl MeshDaemon for BareDaemon {
418        fn name(&self) -> &str {
419            "bare"
420        }
421        fn requirements(&self) -> CapabilityFilter {
422            CapabilityFilter::default()
423        }
424        fn process(&mut self, _event: &CausalEvent) -> Result<Vec<Bytes>, DaemonError> {
425            Ok(Vec::new())
426        }
427    }
428
429    /// Daemon that overrides both new methods. Pins the surface
430    /// daemon authors target when declaring placement requirements.
431    struct GpuDaemon;
432
433    impl MeshDaemon for GpuDaemon {
434        fn name(&self) -> &str {
435            "gpu"
436        }
437        fn requirements(&self) -> CapabilityFilter {
438            CapabilityFilter::default()
439        }
440        fn required_capabilities(&self) -> CapabilitySet {
441            CapabilitySet::new().add_tag("hardware.gpu")
442        }
443        fn optional_capabilities(&self) -> CapabilitySet {
444            CapabilitySet::new().add_tag("hardware.gpu.vram_gb=80")
445        }
446        fn process(&mut self, _event: &CausalEvent) -> Result<Vec<Bytes>, DaemonError> {
447            Ok(Vec::new())
448        }
449    }
450
451    /// Default `required_capabilities()` returns an empty set —
452    /// daemon runs anywhere. Pin so changing the default to a
453    /// non-empty value (which would break backward-compat for
454    /// existing impls) fails build.
455    #[test]
456    fn required_capabilities_default_is_empty() {
457        let d = BareDaemon;
458        let req = d.required_capabilities();
459        assert!(req.tags.is_empty());
460        assert!(req.metadata.is_empty());
461    }
462
463    /// Same for `optional_capabilities`.
464    #[test]
465    fn optional_capabilities_default_is_empty() {
466        let d = BareDaemon;
467        let opt = d.optional_capabilities();
468        assert!(opt.tags.is_empty());
469        assert!(opt.metadata.is_empty());
470    }
471
472    /// An override populates the returned set as expected — pins
473    /// the daemon-author-facing surface.
474    #[test]
475    fn override_populates_required_and_optional() {
476        let d = GpuDaemon;
477        let req = d.required_capabilities();
478        let opt = d.optional_capabilities();
479        assert_eq!(req.tags.len(), 1);
480        assert!(req.tags.iter().any(|t| t.to_string() == "hardware.gpu"));
481        assert_eq!(opt.tags.len(), 1);
482        assert!(opt
483            .tags
484            .iter()
485            .any(|t| t.to_string() == "hardware.gpu.vram_gb=80"));
486    }
487
488    /// The new methods plug into `PlacementFilter` via the
489    /// `Artifact::Daemon { required, optional, .. }` payload.
490    /// Pin the integration shape so a refactor of either side
491    /// surfaces in this test.
492    #[test]
493    fn required_capabilities_drive_artifact_daemon() {
494        use crate::adapter::net::behavior::placement::Artifact;
495        let d = GpuDaemon;
496        let req = d.required_capabilities();
497        let opt = d.optional_capabilities();
498        let _artifact = Artifact::Daemon {
499            daemon_id: [0u8; 32],
500            required: &req,
501            optional: &opt,
502        };
503        // If the artifact type's Daemon variant changes shape,
504        // this construction fails compile.
505    }
506
507    // MeshOS-supervision extension: health / saturation / on_control.
508    // Pin the defaults so a daemon written against the older trait
509    // continues to compile + behave under the new supervisor.
510
511    #[test]
512    fn health_default_is_healthy() {
513        let d = BareDaemon;
514        assert_eq!(d.health(), DaemonHealth::Healthy);
515    }
516
517    #[test]
518    fn saturation_default_is_zero() {
519        let d = BareDaemon;
520        assert_eq!(d.saturation(), 0.0);
521    }
522
523    /// Daemon that overrides the new MeshOS-supervision methods.
524    /// Pins the override surface daemon authors target when they
525    /// participate in graceful shutdown / drain / health reporting.
526    struct WatchedDaemon {
527        last_control: Option<DaemonControl>,
528        health: DaemonHealth,
529        saturation: f32,
530    }
531
532    impl MeshDaemon for WatchedDaemon {
533        fn name(&self) -> &str {
534            "watched"
535        }
536        fn requirements(&self) -> CapabilityFilter {
537            CapabilityFilter::default()
538        }
539        fn process(&mut self, _event: &CausalEvent) -> Result<Vec<Bytes>, DaemonError> {
540            Ok(Vec::new())
541        }
542        fn health(&self) -> DaemonHealth {
543            self.health.clone()
544        }
545        fn saturation(&self) -> f32 {
546            self.saturation
547        }
548        fn on_control(&mut self, event: DaemonControl) {
549            self.last_control = Some(event);
550        }
551    }
552
553    #[test]
554    fn override_surfaces_for_health_and_saturation() {
555        let d = WatchedDaemon {
556            last_control: None,
557            health: DaemonHealth::Degraded {
558                reason: "queue depth".into(),
559            },
560            saturation: 0.42,
561        };
562        assert!(matches!(d.health(), DaemonHealth::Degraded { .. }));
563        assert!((d.saturation() - 0.42).abs() < 1e-6);
564    }
565
566    #[test]
567    fn on_control_receives_supervisor_events() {
568        let mut d = WatchedDaemon {
569            last_control: None,
570            health: DaemonHealth::Healthy,
571            saturation: 0.0,
572        };
573        d.on_control(DaemonControl::Shutdown {
574            grace_period_ms: 5_000,
575        });
576        assert!(matches!(
577            d.last_control,
578            Some(DaemonControl::Shutdown {
579                grace_period_ms: 5_000
580            })
581        ));
582        d.on_control(DaemonControl::BackpressureOn { level: 0.5 });
583        assert!(matches!(
584            d.last_control,
585            Some(DaemonControl::BackpressureOn { level }) if (level - 0.5).abs() < 1e-6
586        ));
587    }
588
589    #[test]
590    fn bare_daemon_ignores_control_events_silently() {
591        // Default `on_control` is a no-op — the daemon
592        // proceeds as normal. Critical for backward
593        // compatibility: existing daemons don't suddenly
594        // change behavior under the new supervisor.
595        let mut d = BareDaemon;
596        d.on_control(DaemonControl::DrainFinish);
597        d.on_control(DaemonControl::BackpressureOff);
598        // No state to assert — the contract is just "no panic,
599        // no side effect."
600    }
601
602    /// Default `MeshDaemon::restore` must refuse non-empty state
603    /// on a stateless daemon (`is_stateful() == false`). The
604    /// pre-fix behavior was a silent `Ok(())` — a daemon that
605    /// *should* have been stateful but forgot to override
606    /// `is_stateful` would have its migrated snapshot bytes
607    /// silently dropped, surfacing as "daemon lost state across
608    /// migration" with no diagnostic. Pin the rejection path AND
609    /// the genuine stateless-to-stateless case (empty bytes
610    /// accepted) so the guard doesn't over-reject either.
611    #[test]
612    fn default_restore_rejects_nonempty_state_on_stateless_daemon() {
613        let mut d = BareDaemon;
614        // Stateless by default.
615        assert!(!d.is_stateful());
616
617        // Empty bytes: stateless-to-stateless migration shape.
618        // Must succeed — otherwise we'd break every migration of
619        // a genuinely stateless daemon.
620        d.restore(Bytes::new())
621            .expect("empty restore on stateless daemon must succeed");
622
623        // Non-empty bytes: misconfiguration signal. Default
624        // impl surfaces it as RestoreFailed with a message that
625        // names the byte count + the required override.
626        let err = d
627            .restore(Bytes::from_static(b"surprise-snapshot-bytes"))
628            .expect_err("non-empty restore on stateless daemon must fail");
629        match err {
630            DaemonError::RestoreFailed(msg) => {
631                assert!(
632                    msg.contains("stateless daemon"),
633                    "error must name the daemon class: {msg}",
634                );
635                assert!(
636                    msg.contains("23"),
637                    "error must include the byte count for triage: {msg}",
638                );
639            }
640            other => panic!("expected RestoreFailed, got {:?}", other),
641        }
642    }
643}