Skip to main content

nexo_core/
config_reload.rs

1//! Runtime config reload coordinator.
2//!
3//! Watches the config directory, re-runs boot validation, builds a
4//! fresh `RuntimeSnapshot` per agent, and dispatches
5//! `ReloadCommand::Apply` to each live runtime through the per-agent
6//! mpsc channel the runtime exposed via `reload_sender()`. The
7//! existing per-agent snapshot is left in place whenever validation
8//! fails or a snapshot cannot be built — servicing never drops to a
9//! broken config.
10//!
11//! Scope: hot-swap of **existing** agents only. Adding a brand-new
12//! agent id or removing a running one requires spawn/teardown
13//! plumbing that lives in `src/main.rs` today.
14
15use std::path::PathBuf;
16use std::sync::Arc;
17use std::time::{Duration, Instant};
18
19use arc_swap::ArcSwapOption;
20use dashmap::DashMap;
21use nexo_broker::{AnyBroker, BrokerHandle};
22use nexo_config::AppConfig;
23use nexo_llm::LlmRegistry;
24use tokio::sync::{mpsc, Mutex};
25use tokio_util::sync::CancellationToken;
26
27use crate::agent::runtime::ReloadCommand;
28use crate::agent::spawn::{AgentSpawnerFn, SharedRuntimeContext};
29use crate::runtime_snapshot::RuntimeSnapshot;
30use crate::telemetry;
31
32/// Per-agent state the coordinator needs to dispatch a reload. Held
33/// in a `DashMap<String, AgentReloadHandle>` keyed by agent id.
34///
35/// `known_tools` captures the agent's tool surface at boot time —
36/// builtins + plugins + MCP + extensions + skills, after the per-agent
37/// allowlist prune. The coordinator uses it during reload so a typo
38/// in `allowed_tools` (binding-level) fails the swap instead of
39/// silently degrading to "agent has no tools at runtime".
40pub struct AgentReloadHandle {
41    pub reload_tx: mpsc::Sender<ReloadCommand>,
42    pub known_tools: Arc<Vec<String>>,
43}
44
45/// Hook the reload coordinator runs after every successful swap.
46/// Used to invalidate process-wide caches that hold a stale view of
47/// data the reload may have changed (e.g. `PairingGate`'s in-memory
48/// allowlist cache, which would otherwise keep blocking a sender the
49/// operator just `nexo pair seed`-ed). Hooks are best-effort; they
50/// run sequentially under the same gate as the reload itself, so
51/// keep them cheap (one mutex / one dashmap clear).
52pub type PostReloadHook = Box<dyn Fn() + Send + Sync>;
53
54/// Reload coordinator. One instance per process; `start` spawns the
55/// file watcher and the broker `control.reload` subscriber.
56pub struct ConfigReloadCoordinator {
57    config_dir: PathBuf,
58    runtimes: DashMap<String, AgentReloadHandle>,
59    llm_registry: Arc<LlmRegistry>,
60    version: Mutex<u64>,
61    /// Serial gate so two overlapping triggers (watcher + CLI) don't
62    /// race the snapshot build. The second trigger queues behind the
63    /// first.
64    gate: Mutex<()>,
65    /// Broker handle attached at `start()`. `Some` once the daemon is
66    /// up; the file-watcher branch uses it to publish
67    /// `events.runtime.config.reloaded` after every successful swap so
68    /// extensions and dashboards can react without polling.
69    broker: ArcSwapOption<AnyBroker>,
70    /// Cache-flush hooks fired after every successful reload. Locked
71    /// by the same gate as the reload to keep the contract simple
72    /// (no observer can run mid-swap).
73    post_hooks: Mutex<Vec<PostReloadHook>>,
74    /// Phase 81.32 — shared runtime context the coordinator
75    /// hands to `spawn_agent_runtime` when an agent id appears
76    /// in the new config that wasn't there before. `None` keeps
77    /// the legacy "adding a new agent at runtime is not
78    /// supported" rejection so tests that haven't wired the
79    /// context stay on the old behaviour.
80    shared_ctx: ArcSwapOption<SharedRuntimeContext>,
81    /// Phase 81.32 c6 — spawner closure the coordinator invokes
82    /// when an unknown agent id appears in `agents.yaml`. `None`
83    /// keeps the legacy rejection ("adding a new agent at
84    /// runtime is not supported"). Installed at boot via
85    /// [`Self::set_spawner`] once `src/main.rs` has finished
86    /// constructing all per-agent dependencies.
87    spawner: ArcSwapOption<AgentSpawnerFn>,
88    shutdown: CancellationToken,
89}
90
91/// Result returned by [`ConfigReloadCoordinator::reload`].
92#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
93pub struct ReloadOutcome {
94    pub version: u64,
95    pub applied: Vec<String>,
96    pub rejected: Vec<ReloadRejection>,
97    pub elapsed_ms: u64,
98}
99
100#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
101pub struct ReloadRejection {
102    pub agent_id: Option<String>,
103    pub reason: String,
104}
105
106impl ConfigReloadCoordinator {
107    pub fn new(
108        config_dir: PathBuf,
109        llm_registry: Arc<LlmRegistry>,
110        shutdown: CancellationToken,
111    ) -> Self {
112        Self {
113            config_dir,
114            runtimes: DashMap::new(),
115            llm_registry,
116            version: Mutex::new(0),
117            gate: Mutex::new(()),
118            broker: ArcSwapOption::from(None),
119            post_hooks: Mutex::new(Vec::new()),
120            shared_ctx: ArcSwapOption::from(None),
121            spawner: ArcSwapOption::from(None),
122            shutdown,
123        }
124    }
125
126    /// Phase 81.32 — install the [`SharedRuntimeContext`] the
127    /// reload coordinator hands to `spawn_agent_runtime` when an
128    /// agent id appears in the freshly-loaded config that wasn't
129    /// in the previous one. Without this wired, the legacy
130    /// rejection ("adding a new agent at runtime is not
131    /// supported") fires for unknown ids.
132    ///
133    /// Late-bindable so `src/main.rs` can build the coordinator
134    /// before the boot-loop singletons are fully assembled and
135    /// upgrade it once they are.
136    pub fn with_shared_context(self, shared: Arc<SharedRuntimeContext>) -> Self {
137        self.shared_ctx.store(Some(shared));
138        self
139    }
140
141    /// Phase 81.32 — read-only handle to the configured shared
142    /// context. `None` when [`Self::with_shared_context`] has
143    /// not been called yet (legacy / test path).
144    pub fn shared_context(&self) -> Option<Arc<SharedRuntimeContext>> {
145        self.shared_ctx.load_full()
146    }
147
148    /// Phase 81.32 c6 — install the spawner closure invoked when
149    /// an unknown agent id appears in `agents.yaml`. Late-bindable
150    /// (same shape as [`Self::with_shared_context`]) so the
151    /// coordinator can exist before the boot loop captures every
152    /// per-agent dependency.
153    pub fn set_spawner(&self, spawner: Arc<AgentSpawnerFn>) {
154        self.spawner.store(Some(spawner));
155    }
156
157    /// Phase 81.32 c6 — read-only handle to the configured
158    /// spawner. `None` when [`Self::set_spawner`] has not yet
159    /// fired; callers fall back to the legacy "not supported"
160    /// rejection.
161    pub fn spawner(&self) -> Option<Arc<AgentSpawnerFn>> {
162        self.spawner.load_full()
163    }
164
165    /// Phase 81.32 — uninstall the per-agent reload handle when
166    /// an agent is hot-removed from `agents.yaml`. Returns the
167    /// handle so the coordinator can drive its
168    /// `ReloadCommand::Shutdown` send before dropping it.
169    pub fn unregister(&self, agent_id: &str) -> Option<AgentReloadHandle> {
170        self.runtimes.remove(agent_id).map(|(_, handle)| handle)
171    }
172
173    /// Register a closure that fires after every successful reload.
174    /// Callers should add their cache-invalidation entry point here
175    /// at boot. Hooks run inside the reload gate so they observe the
176    /// new config but cannot themselves overlap a future reload.
177    pub async fn register_post_hook(&self, hook: PostReloadHook) {
178        self.post_hooks.lock().await.push(hook);
179    }
180
181    /// Register a live agent runtime's reload channel. Called once per
182    /// agent during boot; subsequent reloads look these up to dispatch.
183    pub fn register(
184        &self,
185        agent_id: impl Into<String>,
186        reload_tx: mpsc::Sender<ReloadCommand>,
187        known_tools: Arc<Vec<String>>,
188    ) {
189        self.runtimes.insert(
190            agent_id.into(),
191            AgentReloadHandle {
192                reload_tx,
193                known_tools,
194            },
195        );
196    }
197
198    /// Re-read config, validate, build snapshots, dispatch. Returns an
199    /// aggregate outcome — successful ids in `applied`, per-agent or
200    /// top-level failures in `rejected`. The coordinator never panics
201    /// on a bad config; it logs + bumps the rejected counter and keeps
202    /// old snapshots serving.
203    pub async fn reload(&self) -> ReloadOutcome {
204        let _gate = self.gate.lock().await;
205        let started = Instant::now();
206        let mut applied: Vec<String> = Vec::new();
207        let mut rejected: Vec<ReloadRejection> = Vec::new();
208
209        // 1. Load + env-resolve.
210        let cfg = match AppConfig::load(&self.config_dir) {
211            Ok(c) => c,
212            Err(e) => {
213                telemetry::inc_config_reload_rejected();
214                tracing::warn!(error = %e, "config reload: load failed, keeping previous snapshot");
215                rejected.push(ReloadRejection {
216                    agent_id: None,
217                    reason: format!("AppConfig::load: {e}"),
218                });
219                let current = *self.version.lock().await;
220                return ReloadOutcome {
221                    version: current,
222                    applied,
223                    rejected,
224                    elapsed_ms: started.elapsed().as_millis() as u64,
225                };
226            }
227        };
228
229        // 2. Structural + provider validation (aggregate errors).
230        // Providers are LLM yaml instance ids
231        // (`anthropic-a5b8`), NOT factory ids — agents bind to
232        // yaml-key instances that map to a factory via
233        // `factory_type`. Mirror the boot validation path in
234        // `src/main.rs::validate_agents_with_providers`.
235        let known_providers =
236            crate::agent::KnownProviders::new(cfg.llm.providers.keys().map(String::as_str));
237        if let Err(e) = crate::agent::validate_agents_with_providers(
238            &cfg.agents.agents,
239            &cfg.plugins,
240            &crate::agent::KnownTools::default(),
241            &known_providers,
242        ) {
243            telemetry::inc_config_reload_rejected();
244            tracing::warn!(error = %e, "config reload: validation failed, keeping previous snapshot");
245            rejected.push(ReloadRejection {
246                agent_id: None,
247                reason: format!("validation: {e}"),
248            });
249            let current = *self.version.lock().await;
250            return ReloadOutcome {
251                version: current,
252                applied,
253                rejected,
254                elapsed_ms: started.elapsed().as_millis() as u64,
255            };
256        }
257
258        // 3. Bump version + build snapshots per agent present in BOTH
259        //    old (registered handles) and new (config). Agents that
260        //    disappear or appear are out of scope — we skip them
261        //    with a rejection entry so the operator sees the diff.
262        let mut version_guard = self.version.lock().await;
263        *version_guard += 1;
264        let new_version = *version_guard;
265        drop(version_guard);
266
267        for agent_cfg in &cfg.agents.agents {
268            // Phase 81.32 c8 — unknown agent id (wizard create
269            // path). Try the installed spawner first; fall back to
270            // the legacy rejection only when no spawner was wired
271            // (test harnesses / minimal embeddings). Closure
272            // invocation drops the lock guard via `self.runtimes
273            // .get(...)` returning `Some` only when an entry
274            // already exists; the spawner branch runs OUTSIDE the
275            // guard scope to keep `register(...)` reentrant.
276            if !self.runtimes.contains_key(&agent_cfg.id) {
277                let Some(spawner) = self.spawner() else {
278                    rejected.push(ReloadRejection {
279                        agent_id: Some(agent_cfg.id.clone()),
280                        reason: "adding a new agent at runtime is not supported; \
281                                 set a spawner via ConfigReloadCoordinator::set_spawner"
282                            .into(),
283                    });
284                    continue;
285                };
286                match spawner.call(agent_cfg.clone()).await {
287                    Ok(spawned) => {
288                        self.register(
289                            spawned.agent_id.clone(),
290                            spawned.reload_tx,
291                            spawned.known_tools,
292                        );
293                        applied.push(spawned.agent_id.clone());
294                        // Best-effort firehose notification — operators
295                        // tail the broker events stream to see when a
296                        // wizard-created agent goes live.
297                        if let Some(b) = self.broker.load_full() {
298                            let evt = nexo_broker::Event::new(
299                                "events.runtime.agent.spawned",
300                                "config_reload",
301                                serde_json::json!({
302                                    "agent_id": spawned.agent_id,
303                                    "version": new_version,
304                                }),
305                            );
306                            let _ = b.publish("events.runtime.agent.spawned", evt).await;
307                        }
308                        tracing::info!(
309                            agent = %agent_cfg.id,
310                            "hot-spawned agent via ConfigReloadCoordinator",
311                        );
312                    }
313                    Err(e) => {
314                        rejected.push(ReloadRejection {
315                            agent_id: Some(agent_cfg.id.clone()),
316                            reason: format!("spawn: {e}"),
317                        });
318                    }
319                }
320                continue;
321            }
322            let Some(handle) = self.runtimes.get(&agent_cfg.id) else {
323                // Race window between `contains_key` + `get` —
324                // operator hot-removed the agent mid-reload. Treat
325                // as a rejection for this cycle; the next reload
326                // re-detects.
327                rejected.push(ReloadRejection {
328                    agent_id: Some(agent_cfg.id.clone()),
329                    reason: "agent vanished between hot-spawn check and snapshot build".into(),
330                });
331                continue;
332            };
333
334            // Per-agent post-assembly tool-name validation. Mirrors
335            // the boot-path second-pass check so a binding's typo'd
336            // `allowed_tools` rejects the reload instead of silently
337            // landing a config that the runtime then has to translate
338            // into a "tool not available" error every turn.
339            let known_strs: Vec<&str> = handle.known_tools.iter().map(|s| s.as_str()).collect();
340            let catalog = crate::agent::KnownTools::new(known_strs);
341            if let Err(e) = crate::agent::validate_agent(agent_cfg, &cfg.plugins, &catalog) {
342                rejected.push(ReloadRejection {
343                    agent_id: Some(agent_cfg.id.clone()),
344                    reason: format!("post-assembly validation: {e}"),
345                });
346                continue;
347            }
348
349            let snap = match RuntimeSnapshot::build(
350                Arc::new(agent_cfg.clone()),
351                &self.llm_registry,
352                &cfg.llm,
353                new_version,
354            ) {
355                Ok(s) => Arc::new(s),
356                Err(e) => {
357                    rejected.push(ReloadRejection {
358                        agent_id: Some(agent_cfg.id.clone()),
359                        reason: format!("snapshot build: {e}"),
360                    });
361                    continue;
362                }
363            };
364
365            match handle.reload_tx.send(ReloadCommand::Apply(snap)).await {
366                Ok(()) => applied.push(agent_cfg.id.clone()),
367                Err(e) => rejected.push(ReloadRejection {
368                    agent_id: Some(agent_cfg.id.clone()),
369                    reason: format!("dispatch: {e}"),
370                }),
371            }
372        }
373
374        // 4. Detect removed agents (registered but absent from new cfg).
375        // Phase 81.32 c9 — hot-teardown. Send `ReloadCommand::Shutdown`
376        // to the per-agent runtime so it drops broker subs +
377        // heartbeat tasks cleanly, then `unregister` the handle so
378        // future reloads can `hot-spawn` the same id without
379        // colliding with stale state. Best-effort event publish so
380        // operators watching the firehose see hot-remove distinctly
381        // from a regular reload.
382        //
383        // Collect removed ids first (can't mutate the map while
384        // iterating it; DashMap reentrant remove panics on the same
385        // shard).
386        let removed_ids: Vec<String> = self
387            .runtimes
388            .iter()
389            .filter_map(|entry| {
390                let id = entry.key();
391                if !cfg.agents.agents.iter().any(|a| &a.id == id) {
392                    Some(id.clone())
393                } else {
394                    None
395                }
396            })
397            .collect();
398        for id in removed_ids {
399            let Some(handle) = self.unregister(&id) else {
400                continue;
401            };
402            // Best-effort `Shutdown` dispatch. A full mailbox /
403            // closed channel here means the runtime task already
404            // exited — log + carry on so the operator still sees
405            // the agent disappear from `runtimes`.
406            if let Err(e) = handle.reload_tx.send(ReloadCommand::Shutdown).await {
407                tracing::warn!(
408                    agent = %id,
409                    error = %e,
410                    "hot-remove: runtime mailbox dispatch failed (task may have exited already)",
411                );
412            }
413            if let Some(b) = self.broker.load_full() {
414                let evt = nexo_broker::Event::new(
415                    "events.runtime.agent.removed",
416                    "config_reload",
417                    serde_json::json!({
418                        "agent_id": id,
419                        "version": new_version,
420                    }),
421                );
422                let _ = b.publish("events.runtime.agent.removed", evt).await;
423            }
424            applied.push(id.clone());
425            tracing::info!(agent = %id, "hot-removed agent via ConfigReloadCoordinator");
426        }
427
428        let elapsed_ms = started.elapsed().as_millis() as u64;
429        telemetry::observe_config_reload_latency_ms(elapsed_ms);
430
431        if !applied.is_empty() {
432            telemetry::inc_config_reload_applied();
433            tracing::info!(
434                version = new_version,
435                applied = ?applied,
436                rejected_count = rejected.len(),
437                elapsed_ms,
438                "config reload applied",
439            );
440            // Run cache-invalidation hooks (e.g. PairingGate flush)
441            // before publishing the reload event, so consumers see
442            // the new state cleanly. We hold the lock briefly — the
443            // gate above already prevents overlapping reloads, the
444            // post-hooks lock just guards the registration list.
445            let hooks = self.post_hooks.lock().await;
446            for hook in hooks.iter() {
447                hook();
448            }
449            drop(hooks);
450            // Broadcast the event so extensions
451            // and dashboards can react without polling. Non-fatal if
452            // the publish fails; the metrics + log already record the
453            // swap.
454            if let Some(broker) = self.broker.load_full() {
455                let payload = serde_json::json!({
456                    "version": new_version,
457                    "applied": &applied,
458                    "rejected": &rejected,
459                    "elapsed_ms": elapsed_ms,
460                });
461                let topic = "events.runtime.config.reloaded";
462                let evt = nexo_broker::Event::new(topic, "config-reload", payload);
463                if let Err(e) = broker.publish(topic, evt).await {
464                    tracing::warn!(error = %e, "failed to publish events.runtime.config.reloaded");
465                }
466            }
467        }
468        if !rejected.is_empty() {
469            tracing::warn!(
470                version = new_version,
471                rejected = ?rejected,
472                "config reload: partial rejects",
473            );
474        }
475
476        ReloadOutcome {
477            version: new_version,
478            applied,
479            rejected,
480            elapsed_ms,
481        }
482    }
483
484    /// Start the watcher + broker subscriber. Returns immediately; the
485    /// work runs on spawned tasks that honour `self.shutdown`.
486    pub async fn start(
487        self: Arc<Self>,
488        broker: AnyBroker,
489        reload: nexo_config::RuntimeReloadConfig,
490    ) -> anyhow::Result<()> {
491        if !reload.enabled {
492            tracing::info!("config hot-reload disabled via runtime.yaml");
493            return Ok(());
494        }
495
496        // Stash the broker so reload() can emit
497        // `events.runtime.config.reloaded` regardless of whether the
498        // trigger came from the file watcher or the CLI.
499        self.broker.store(Some(Arc::new(broker.clone())));
500
501        // File watcher → debounced notifications.
502        let watcher_rx = crate::config_watch::spawn_config_watcher(
503            self.config_dir.clone(),
504            reload.extra_watch_paths.clone(),
505            Duration::from_millis(reload.debounce_ms),
506            self.shutdown.clone(),
507        )?;
508        let coord_watcher = Arc::clone(&self);
509        tokio::spawn(async move {
510            let mut rx = watcher_rx;
511            while let Some(()) = rx.recv().await {
512                if coord_watcher.shutdown.is_cancelled() {
513                    break;
514                }
515                let _ = coord_watcher.reload().await;
516            }
517        });
518
519        // Broker subscriber — manual triggers from `agent reload`.
520        let mut sub = broker.subscribe("control.reload").await?;
521        let coord_broker = Arc::clone(&self);
522        let broker_clone = broker.clone();
523        tokio::spawn(async move {
524            loop {
525                if coord_broker.shutdown.is_cancelled() {
526                    break;
527                }
528                let Some(_event) = sub.next().await else {
529                    break;
530                };
531                let outcome = coord_broker.reload().await;
532                let ack_topic = "control.reload.ack";
533                let payload = serde_json::to_value(&outcome)
534                    .unwrap_or_else(|e| serde_json::json!({ "error": e.to_string() }));
535                let evt = nexo_broker::Event::new(ack_topic, "config-reload", payload);
536                if let Err(e) = broker_clone.publish(ack_topic, evt).await {
537                    tracing::warn!(error = %e, "failed to publish control.reload.ack");
538                }
539            }
540        });
541
542        Ok(())
543    }
544
545    /// Current monotonic version (for telemetry / tests).
546    pub async fn version(&self) -> u64 {
547        *self.version.lock().await
548    }
549}
550
551#[cfg(test)]
552impl ConfigReloadCoordinator {
553    /// Test-only: count of registered post hooks.
554    /// Used by `register_plugin_registry_reload_hook` to verify
555    /// it pushes exactly one hook.
556    pub async fn post_hooks_len_for_test(&self) -> usize {
557        self.post_hooks.lock().await.len()
558    }
559
560    /// Test-only: fire every registered post-hook in FIFO order.
561    /// Mirrors the production fire path but skips the gate + reload
562    /// itself; callers exercise the hook contract, not the reload
563    /// mechanics.
564    pub async fn fire_post_hooks_for_test(&self) {
565        let hooks = self.post_hooks.lock().await;
566        for hook in hooks.iter() {
567            hook();
568        }
569    }
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575
576    #[tokio::test]
577    async fn reload_with_no_config_dir_falls_back_to_defaults() {
578        // Phase 93 — `AppConfig::load` tolerates a missing config dir
579        // by returning `Default::default()` (same as the daemon's
580        // zero-config boot path). A hot-reload pointed at a
581        // nonexistent dir is therefore a clean no-op: defaults
582        // validate, there are 0 agents to hot-swap, nothing rejected.
583        let coord = Arc::new(ConfigReloadCoordinator::new(
584            PathBuf::from("/nonexistent-config-dir-xyz"),
585            Arc::new(LlmRegistry::with_builtins()),
586            CancellationToken::new(),
587        ));
588        let outcome = coord.reload().await;
589        assert!(
590            outcome.rejected.is_empty(),
591            "a missing config dir is tolerated, not rejected: {:?}",
592            outcome.rejected
593        );
594        assert!(
595            outcome.applied.is_empty(),
596            "default config has no agents to apply: {:?}",
597            outcome.applied
598        );
599    }
600
601    #[tokio::test]
602    async fn version_starts_at_zero() {
603        let coord = ConfigReloadCoordinator::new(
604            PathBuf::from("."),
605            Arc::new(LlmRegistry::with_builtins()),
606            CancellationToken::new(),
607        );
608        assert_eq!(coord.version().await, 0);
609    }
610
611    #[tokio::test]
612    async fn post_hooks_register_and_can_be_invoked_in_order() {
613        // Verify the hook list grows and runs in
614        // registration order. The reload() success path that fires
615        // them needs a full AppConfig on disk; that's covered by the
616        // boot smoke tests. Here we just check the storage / FIFO.
617        use std::sync::atomic::{AtomicUsize, Ordering};
618        let coord = ConfigReloadCoordinator::new(
619            PathBuf::from("."),
620            Arc::new(LlmRegistry::with_builtins()),
621            CancellationToken::new(),
622        );
623        let order = Arc::new(AtomicUsize::new(0));
624        let a_witness = Arc::new(AtomicUsize::new(0));
625        let b_witness = Arc::new(AtomicUsize::new(0));
626        {
627            let order = Arc::clone(&order);
628            let w = Arc::clone(&a_witness);
629            coord
630                .register_post_hook(Box::new(move || {
631                    w.store(order.fetch_add(1, Ordering::SeqCst) + 1, Ordering::SeqCst);
632                }))
633                .await;
634        }
635        {
636            let order = Arc::clone(&order);
637            let w = Arc::clone(&b_witness);
638            coord
639                .register_post_hook(Box::new(move || {
640                    w.store(order.fetch_add(1, Ordering::SeqCst) + 1, Ordering::SeqCst);
641                }))
642                .await;
643        }
644        let hooks = coord.post_hooks.lock().await;
645        assert_eq!(hooks.len(), 2);
646        for hook in hooks.iter() {
647            hook();
648        }
649        drop(hooks);
650        assert_eq!(a_witness.load(Ordering::SeqCst), 1);
651        assert_eq!(b_witness.load(Ordering::SeqCst), 2);
652    }
653}