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}