flybrain/services/flysim/crates/fly-session/src/harness.rs
dev 6655a1b1c6 session: coherent all-participant checkpoint and recovery
STATE-01 over the FLYSESS1 envelope CONTRACT-01 specified.

fly-session gains a `state` module: the durable store with its generations, its
rotation and the commit order of checkpoint-envelope-v1 section 5, where the store
manifest rename is the durable commit point; a compatibility block whose comparison
names the identity that differs rather than one opaque digest; and a bounded writer
that owns its payload handles until the bytes are committed or the job fails.

The writer's queue slot is taken before the first State.Capture, so a saturated
writer refuses a capture rather than queueing it without bound, and the refusal is a
BUSY the stepping session survives. Capture and durability are two events: a capture
completes when an immutable capture exists, and only the store manifest rename moves
the durable mark. A lost save reply is an outcome, and the resolution asks the store
about the same checkpoint instead of saving again.

Both worker roles implement State.Capture, State.StageRestore and
State.ActivateRestore, with once-only restore tokens bound to checkpoint, scope,
payload and incarnation. A restore selects a complete compatible generation, imports
every payload as a fresh artifact, stages the group, validates the coordinator's own
ledgers, and only then activates; a failure anywhere leaves the fence closed and
records every participant that staged as one that must be replaced. The fence lifts
at Failed -> Restoring(k) -> Paused(k) and nowhere else.

The task and the action executor gain the capture/validate_restore/install_restore
interfaces workers-v1 section 4 lists, and the ledger can re-derive the event
identities it issued under another epoch, which is what lets a resumed run's
behaviour trace be compared with an uninterrupted one.

media: check_required_audio now takes the observation's provenance instead of
exempting boundary 0. A chunk is the audio of an interval, and the observation
ActivateRestore installs covers none.

checkpoint-envelope-v1 section 3 gains a dated amendment adding `environment` to the
manifest, the holder of the world's own payload, which the table named for every
other participant; `helperState`, which that table already listed, joins the
required-field set in Rust and TypeScript. The fixture was regenerated by the
existing example; the schema set and contractDigest are unchanged.

state-media-v1 section 5 gains a dated amendment for three readings this slice
enforces: the State RPCs' compatibilityDigest is the participant's, not the
manifest's composition-level block; a restored observation carries no audio chunk;
and a participant that staged into an abandoned install must be replaced.
2026-09-22 17:43:43 +00:00

642 lines
26 KiB
Rust

//! The runnable synthetic composition: one router, the configured flies, one counter arena and
//! one coordinator, in whichever execution mode the composition asks for.
//!
//! All participants use router semantics even when colocated, so every mode and both
//! transports exercise the same code. The caller owns the store root directory, which keeps
//! this module free of a temporary-directory dependency.
//!
//! The three execution modes are the SESSION-02 comparison:
//!
//! | Mode | Where each participant runs | Transport |
//! | --- | --- | --- |
//! | [`ExecutionMode::InProcess`] | A task on the coordinator's runtime | either |
//! | [`ExecutionMode::Thread`] | Its own OS thread and runtime | Unix socket |
//! | [`ExecutionMode::Process`] | Its own process, one per fly plus one world | Unix socket |
use std::collections::BTreeMap;
use std::path::Path;
use std::sync::Mutex;
use flybus::{Client, Grants, Pattern, Policy, Router, RouterConfig};
use crate::agent::{AgentFaults, synthetic_profile};
use crate::coordinator::{AgentSlot, Coordinator};
use crate::environment::EnvironmentFaults;
use crate::media::{RenderCounter, SensorLog};
use crate::launcher::{
AgentLaunch, EnvironmentLaunch, Launcher, ReapOutcome, SUPERVISOR_CLIENT, ThreadBudget,
};
use crate::state::{CheckpointStore, CheckpointWriter, StoreConfig, StoreFaults, WriterConfig, WriterFaults};
use crate::task::{ActionExecutor, CounterTask, IdentityExecutor, Terminal};
// `crate::types` is this crate's facade over the shared `fly-session-types` crate; the
// glob keeps the contract's own names in sight instead of restating them.
use crate::types::*;
use crate::worker::StatusCell;
pub use crate::launcher::{ExecutionMode, Via};
/// One agent in the composition.
#[derive(Clone, Debug)]
pub struct AgentSpec {
pub agent_id: Id,
pub port_id: Id,
/// An explicit seed. The first synthetic composition supports hand-selected seeds; the
/// derivation algorithm is specified before the real agent slice.
pub seed: i32,
pub faults: AgentFaults,
/// The threads this agent asks the launcher for. `Agent.Initialize` carries exactly what
/// the launcher allocated, which `workers-v1` requires it to lie within.
pub worker_threads: usize,
}
impl AgentSpec {
/// One agent on one thread, with no injected fault.
pub fn new(agent_id: &str, port_id: &str, seed: i32) -> AgentSpec {
AgentSpec {
agent_id: id(agent_id),
port_id: id(port_id),
seed,
faults: AgentFaults::default(),
worker_threads: 1,
}
}
}
/// The composition the harness builds.
#[derive(Clone, Debug)]
pub struct HarnessConfig {
pub session_id: Id,
pub epoch: Id,
pub episode_id: Id,
pub agents: Vec<AgentSpec>,
/// The world's cadence. 60 Hz with a 1 ms tick is the `step-v1` section 5 example.
pub step_hz: u64,
pub tick_ms: u64,
pub warmup_ticks: u64,
pub terminal: Terminal,
/// The view's declared render delay, in steps. Zero is same-boundary output.
pub observation_delay_steps: u64,
pub environment_faults: EnvironmentFaults,
/// Where each participant runs.
pub mode: ExecutionMode,
/// The total thread allocation the launcher may hand out. `None` sizes it from the
/// composition and the machine, which is what an ordinary run wants; a test that means to
/// exhaust the budget names a number.
pub thread_budget: Option<usize>,
/// The threads reserved for the coordinator, its router and its store.
pub coordinator_threads: usize,
pub environment_threads: usize,
/// How many committed generations the durable checkpoint store keeps.
pub store: StoreConfig,
/// The durable write faults this composition injects.
pub store_faults: StoreFaults,
/// The checkpoint queue's bounds.
pub writer: WriterConfig,
/// The writer faults this composition injects.
pub writer_faults: WriterFaults,
}
impl Default for HarnessConfig {
fn default() -> HarnessConfig {
HarnessConfig {
session_id: id("demo"),
epoch: id("e1"),
episode_id: id("ep1"),
agents: vec![
AgentSpec { seed: 7, ..AgentSpec::new("fly-a", "p1", 7) },
AgentSpec { seed: 11, ..AgentSpec::new("fly-b", "p2", 11) },
],
step_hz: 60,
tick_ms: 1,
warmup_ticks: 10,
terminal: Terminal::Never,
observation_delay_steps: 0,
environment_faults: EnvironmentFaults::default(),
mode: ExecutionMode::InProcess,
thread_budget: None,
coordinator_threads: 1,
environment_threads: 1,
store: StoreConfig::default(),
store_faults: StoreFaults::default(),
writer: WriterConfig::default(),
writer_faults: WriterFaults::default(),
}
}
}
impl HarnessConfig {
/// The threads this composition needs at a minimum: the coordinator, the world and every
/// agent's own allocation.
pub fn required_threads(&self) -> usize {
self.coordinator_threads
+ self.environment_threads
+ self.agents.iter().map(|a| a.worker_threads).sum::<usize>()
}
/// The budget the launcher runs under: what was configured, or a budget that covers both
/// this composition and this machine's physical cores.
pub fn budget(&self) -> Result<ThreadBudget, DomainError> {
let total = self
.thread_budget
.unwrap_or_else(|| crate::metrics::physical_cores().max(self.required_threads()));
ThreadBudget::new(total, self.coordinator_threads)
}
}
const ENV_SERVICE: &str = "env.arena";
const ENV_CLIENT: &str = "environment";
const ENV_WORKER: &str = "arena";
const COORDINATOR_CLIENT: &str = "coordinator";
/// The checkpoint writer's own bus identity. It publishes checkpoint events and nothing else.
const WRITER_CLIENT: &str = "checkpoint-writer";
/// How many times one participant may be replaced in a composition.
///
/// Each replacement connects under its own client id, so a restart is visibly a new
/// participant rather than a silent reattachment, and the policy has to name them all.
const MAX_GENERATIONS: u32 = 8;
fn agent_service(agent_id: &Id) -> String {
format!("agent.{agent_id}")
}
fn agent_client(agent_id: &Id) -> String {
format!("worker-{agent_id}")
}
fn grants(f: impl FnOnce(&mut Grants)) -> Grants {
let mut g = Grants::default();
f(&mut g);
g
}
/// What a restarted worker looks like from the outside: a new registration and a new
/// incarnation, both different from the ones the coordinator pinned.
#[derive(Clone, Debug)]
pub struct Restarted {
pub service: String,
pub service_incarnation: String,
pub incarnation_id: Id,
}
/// A running synthetic session.
pub struct SessionHarness {
pub coordinator: Coordinator,
pub config: HarnessConfig,
pub via: Via,
pub mode: ExecutionMode,
/// The media instrumentation of the participants that live in this process. Both are
/// shared memory, so both are empty for a participant with a process of its own; the
/// accessors below return `None` there rather than zero.
renders: RenderCounter,
sensors: BTreeMap<Id, SensorLog>,
/// The supervisor. It owns every participant's lifetime and thread allocation.
pub launcher: Launcher,
observers: Mutex<Vec<Client>>,
/// Which generation of each participant is running: 1 is the one the composition started.
generations: BTreeMap<Id, u32>,
/// Where the durable checkpoint store lives, for a test that reads the files themselves.
checkpoint_root: std::path::PathBuf,
}
impl SessionHarness {
/// Builds the router, launches the workers and builds the coordinator. Nothing has
/// stepped yet.
pub async fn start(
via: Via,
root: &Path,
config: HarnessConfig,
) -> Result<SessionHarness, flybus::BusError> {
let store_root = root.join("store");
let sockets = root.join("sockets");
std::fs::create_dir_all(&sockets).expect("the caller owns a writable directory");
// The launcher's policy: who may connect, and what each may do. Naming a target is not
// authority to use it, so the supervisor calls but never registers or publishes, and a
// worker registers exactly one service and calls nothing.
let mut policy = Policy::closed()
.client(
COORDINATOR_CLIENT,
grants(|g| {
g.call = vec![Pattern::prefix("agent."), Pattern::prefix("env.")];
g.publish = vec![Pattern::prefix("session.")];
g.manage_topics = vec![Pattern::prefix("session.")];
}),
)
.client(
SUPERVISOR_CLIENT,
grants(|g| {
g.call = vec![Pattern::prefix("agent."), Pattern::prefix("env.")];
}),
)
// The writer publishes the checkpoint events and never calls a participant.
.client(WRITER_CLIENT, grants(|g| g.publish = vec![Pattern::prefix("session.")]))
.client(ENV_CLIENT, grants(|g| g.register = vec![Pattern::exact(ENV_SERVICE)]))
.client("observer", grants(|g| g.subscribe = vec![Pattern::prefix("session.")]));
for generation in 2..=MAX_GENERATIONS {
policy = policy.client(
&format!("{ENV_CLIENT}-r{generation}"),
grants(|g| g.register = vec![Pattern::exact(ENV_SERVICE)]),
);
}
for spec in &config.agents {
let service = agent_service(&spec.agent_id);
policy = policy.client(
&agent_client(&spec.agent_id),
grants(|g| g.register = vec![Pattern::exact(&service)]),
);
// A replacement worker connects under its own client id, so a restart is visibly a
// new participant rather than a silent reattachment to the active epoch.
for generation in 2..=MAX_GENERATIONS {
policy = policy.client(
&format!("{}-r{generation}", agent_client(&spec.agent_id)),
grants(|g| g.register = vec![Pattern::exact(&service)]),
);
}
}
let mut router_config = RouterConfig::new(&store_root);
router_config.policy = policy;
let router = Router::new(router_config).map_err(|e| {
flybus::BusError::new(flybus::ErrorCode::StoreFailure, format!("router: {e}"))
})?;
let budget = config.budget().map_err(refusal)?;
let mut launcher =
Launcher::start(router, config.mode, via, &store_root, &sockets, budget).await?;
let step_duration = hz(config.step_hz).expect("a positive cadence");
let tick_duration = millis(config.tick_ms).expect("a positive tick");
let renders = RenderCounter::new();
let sensors: BTreeMap<Id, SensorLog> = config
.agents
.iter()
.map(|spec| (spec.agent_id.clone(), SensorLog::new()))
.collect();
// The environment first: it owns the world and the descriptor.
let environment = launcher
.launch_environment(EnvironmentLaunch {
session_id: config.session_id.clone(),
worker_id: id(ENV_WORKER),
incarnation_id: id("arena-inc-1"),
step_duration,
ports: config.agents.iter().map(|a| a.port_id.clone()).collect(),
worker_threads: config.environment_threads,
observation_delay_steps: config.observation_delay_steps,
renders: renders.clone(),
faults: config.environment_faults.clone(),
client_id: ENV_CLIENT.to_owned(),
service: ENV_SERVICE.to_owned(),
})
.await
.map_err(refusal)?;
let environment_ref = launcher
.worker(&environment.worker_id)
.expect("just launched")
.worker_ref();
let mut slots = Vec::new();
for spec in &config.agents {
let identity = launcher
.launch_agent(AgentLaunch {
session_id: config.session_id.clone(),
agent_id: spec.agent_id.clone(),
port_id: spec.port_id.clone(),
incarnation_id: parse_id(&format!("{}-inc-1", spec.agent_id))
.expect("an agent id plus a suffix is an Id"),
tick_duration,
warmup_ticks: config.warmup_ticks,
worker_threads: spec.worker_threads,
sensors: sensors[&spec.agent_id].clone(),
faults: spec.faults.clone(),
client_id: agent_client(&spec.agent_id),
service: agent_service(&spec.agent_id),
})
.await
.map_err(refusal)?;
let worker_ref = launcher
.worker(&spec.agent_id)
.expect("just launched")
.worker_ref();
let mut slot = AgentSlot::new(
worker_ref,
spec.agent_id.clone(),
spec.port_id.clone(),
synthetic_profile(&spec.agent_id, &tick_duration, config.warmup_ticks),
spec.seed,
);
slot.worker_threads = identity.worker_threads as u64;
slots.push(slot);
}
let coordinator_client = launcher.connect(COORDINATOR_CLIENT).await?;
// The durable store lives beside the router's artifact store and never inside it: a
// committed generation is outside the bus's ephemeral collection.
let checkpoint_root = root.join("checkpoints");
let mut store = CheckpointStore::open(&checkpoint_root, config.store).map_err(refusal)?;
*store.faults_mut() = config.store_faults.clone();
let writer_client = launcher.connect(WRITER_CLIENT).await?;
let writer = CheckpointWriter::start(
store,
config.writer,
config.writer_faults.clone(),
Some((
writer_client,
format!("session.{}.checkpoints", config.session_id),
)),
);
let executors: BTreeMap<Id, Box<dyn ActionExecutor>> = config
.agents
.iter()
.map(|spec| {
(spec.agent_id.clone(), Box::new(IdentityExecutor) as Box<dyn ActionExecutor>)
})
.collect();
let coordinator = Coordinator::new(
coordinator_client,
config.session_id.clone(),
config.epoch.clone(),
config.episode_id.clone(),
environment_ref,
slots,
Box::new(CounterTask::new(&config.epoch, config.terminal)),
executors,
);
let mut coordinator = coordinator;
coordinator.attach_store(writer);
Ok(SessionHarness {
coordinator,
config,
via,
mode: launcher.mode(),
renders,
sensors,
launcher,
observers: Mutex::new(Vec::new()),
generations: BTreeMap::new(),
checkpoint_root,
})
}
/// Where the durable checkpoint store's generations and store manifest live.
pub fn checkpoint_root(&self) -> &std::path::Path {
&self.checkpoint_root
}
pub fn router(&self) -> &Router {
self.launcher.router()
}
/// The coordinator and its supervisor, borrowed apart.
///
/// A supervisor acts while a transition is in flight -- that is what a supervisor is for
/// -- so the two have to be reachable at the same time.
pub fn parts(&mut self) -> (&mut Coordinator, &mut Launcher) {
(&mut self.coordinator, &mut self.launcher)
}
/// The router's own counters: owners, roots, queued messages and store bytes.
pub fn router_stats(&self) -> flybus::RouterStats {
self.launcher.router().stats()
}
/// A client for `id`, connected the same way every participant is.
///
/// An unconfigured client id is refused by the launcher's policy before it can route, so
/// this is not a way around the composition.
pub async fn client(&self, id: &str) -> Result<Client, flybus::BusError> {
self.launcher.connect(id).await
}
/// An extra subscriber, for a test that watches the published boundaries.
pub async fn observer(&self) -> Result<Client, flybus::BusError> {
let client = self.launcher.connect("observer").await?;
self.observers.lock().expect("not poisoned").push(client.clone());
Ok(client)
}
/// Replaces one agent's worker with a fresh incarnation, as a restore would.
///
/// The coordinator still pins the old registration, so its next call to that agent fails
/// rather than silently reaching another brain.
pub async fn restart_agent(&mut self, agent_id: &Id) -> Result<Restarted, flybus::BusError> {
let spec = self
.config
.agents
.iter()
.find(|spec| spec.agent_id == *agent_id)
.expect("a configured agent")
.clone();
let generation = self.next_generation(agent_id)?;
self.launcher.kill(agent_id).await;
let tick_duration = millis(self.config.tick_ms).expect("a positive tick");
let incarnation_id = parse_id(&format!("{agent_id}-inc-{generation}"))
.expect("an agent id plus a suffix is an Id");
self.launcher
.launch_agent(AgentLaunch {
session_id: self.config.session_id.clone(),
agent_id: agent_id.clone(),
port_id: spec.port_id.clone(),
incarnation_id: incarnation_id.clone(),
tick_duration,
warmup_ticks: self.config.warmup_ticks,
worker_threads: spec.worker_threads,
// The same log: a replacement worker in this process keeps writing where its
// predecessor wrote, so a restore's sensory input is visible beside it.
sensors: self.sensors.get(agent_id).cloned().unwrap_or_default(),
faults: spec.faults.clone(),
client_id: format!("{}-r{generation}", agent_client(agent_id)),
service: agent_service(agent_id),
})
.await
.map_err(refusal)?;
let worker = self.launcher.worker(agent_id).expect("just launched");
Ok(Restarted {
service: worker.identity.service.clone(),
service_incarnation: worker.service_incarnation.clone(),
incarnation_id,
})
}
/// Replaces the environment with a fresh, uninitialized incarnation, as a restore needs.
pub async fn restart_environment(&mut self) -> Result<Restarted, flybus::BusError> {
let worker_id = id(ENV_WORKER);
let generation = self.next_generation(&worker_id)?;
self.launcher.kill(&worker_id).await;
let step_duration = hz(self.config.step_hz).expect("a positive cadence");
let incarnation_id = parse_id(&format!("arena-inc-{generation}"))
.expect("a worker id plus a suffix is an Id");
self.launcher
.launch_environment(EnvironmentLaunch {
session_id: self.config.session_id.clone(),
worker_id: worker_id.clone(),
incarnation_id: incarnation_id.clone(),
step_duration,
ports: self.config.agents.iter().map(|a| a.port_id.clone()).collect(),
worker_threads: self.config.environment_threads,
observation_delay_steps: self.config.observation_delay_steps,
renders: self.renders.clone(),
faults: self.config.environment_faults.clone(),
client_id: format!("{ENV_CLIENT}-r{generation}"),
service: ENV_SERVICE.to_owned(),
})
.await
.map_err(refusal)?;
let worker = self.launcher.worker(&worker_id).expect("just launched");
Ok(Restarted {
service: worker.identity.service.clone(),
service_incarnation: worker.service_incarnation.clone(),
incarnation_id,
})
}
fn next_generation(&mut self, worker_id: &Id) -> Result<u32, flybus::BusError> {
let slot = self.generations.entry(worker_id.clone()).or_insert(1);
if *slot >= MAX_GENERATIONS {
return Err(flybus::BusError::new(
flybus::ErrorCode::QuotaExceeded,
format!(
"{worker_id} has used all {MAX_GENERATIONS} configured client identities; a composition declares how many replacements it allows"
),
));
}
*slot += 1;
Ok(*slot)
}
/// Replaces every participant and points the fenced coordinator at the replacements.
///
/// This is what a recovery does before it restores: the old participants belong to an
/// invalid epoch, and the references the coordinator pinned are exchanged deliberately.
pub async fn replace_all_participants(&mut self) -> Result<(), flybus::BusError> {
let environment = self.environment_id();
self.restart_environment().await?;
let worker = self
.launcher
.worker(&environment)
.expect("just launched")
.worker_ref();
self.coordinator
.replace_participant(&environment, worker)
.map_err(|e| refusal(e.error))?;
for agent_id in self.config.agents.iter().map(|a| a.agent_id.clone()).collect::<Vec<_>>() {
self.restart_agent(&agent_id).await?;
let worker = self
.launcher
.worker(&agent_id)
.expect("just launched")
.worker_ref();
self.coordinator
.replace_participant(&agent_id, worker)
.map_err(|e| refusal(e.error))?;
}
Ok(())
}
/// Changes one agent's injected faults, so the replacement the next restart launches is
/// a participant without them.
///
/// A fault is launch configuration, so clearing one is a relaunch and not a live change:
/// the worker running now keeps whatever it was started with.
pub fn set_agent_faults(&mut self, agent_id: &Id, faults: AgentFaults) {
if let Some(spec) = self
.config
.agents
.iter_mut()
.find(|spec| spec.agent_id == *agent_id)
{
spec.faults = faults;
}
}
/// Changes the environment's injected faults, with the same relaunch rule.
pub fn set_environment_faults(&mut self, faults: EnvironmentFaults) {
self.config.environment_faults = faults;
}
/// Ends one participant without asking it, as a crash would.
pub async fn kill(&mut self, worker_id: &Id) -> ReapOutcome {
self.launcher.kill(worker_id).await
}
/// The worker id the environment answers to.
pub fn environment_id(&self) -> Id {
id(ENV_WORKER)
}
/// What one agent read out of its sensory attachments, in order, when this process is
/// where that log lives.
///
/// `None` means "not observable from here", not "nothing was read": an agent with a
/// process of its own records into its own copy. The media path itself crosses a process
/// boundary -- the frame is one artifact in the shared store, reached through owned
/// handles -- but this instrumentation does not, because it is shared memory.
pub fn sensor_log(&self, agent_id: &Id) -> Option<SensorLog> {
match self.mode {
ExecutionMode::Process => None,
_ => self.sensors.get(agent_id).cloned(),
}
}
/// How many native frames the environment rendered, when the world lives in this process.
///
/// `None` for a world with a process of its own, for the same reason as above.
pub fn renders(&self) -> Option<u64> {
match self.mode {
ExecutionMode::Process => None,
_ => Some(self.renders.count()),
}
}
/// The agent worker's progress counter, which is its fake model's mutation count, when
/// this process is where that counter lives.
///
/// `None` means "not observable from here", not "nothing happened": a participant with a
/// process of its own keeps its counter there. [`SessionHarness::progress_of`] reads it
/// over the bus and works in every mode.
pub fn agent_mutations(&self, agent_id: &Id) -> Option<u64> {
self.launcher
.worker(agent_id)
.and_then(crate::launcher::LaunchedWorker::progress_counter)
}
pub fn environment_mutations(&self) -> Option<u64> {
self.agent_mutations(&id(ENV_WORKER))
}
/// One participant's progress counter, read over the bus. Works in every execution mode.
pub async fn progress_of(&mut self, worker_id: &Id) -> Result<u64, DomainError> {
Ok(self.launcher.health_check(worker_id).await?.progress_counter)
}
/// The local status cell of a participant in this process, or `None` for one with a
/// process of its own.
pub fn agent_status(&self, agent_id: &Id) -> Option<StatusCell> {
self.launcher.worker(agent_id).and_then(crate::launcher::LaunchedWorker::status)
}
/// Reaps every participant and closes the router.
pub async fn shutdown(self) {
let SessionHarness { mut coordinator, mut launcher, observers, .. } = self;
// The writer task owns artifact handles and a blocking store. Leaving it running
// would leave both behind.
coordinator.shutdown_store().await;
drop(coordinator);
launcher.reap_all(&id("shutdown")).await;
for observer in observers.into_inner().expect("not poisoned") {
observer.close().await;
}
launcher.router().shutdown();
}
}
/// A launcher refusal, as a bus error: the harness's one error type stays the bus's.
fn refusal(e: DomainError) -> flybus::BusError {
let code = match e.code {
ErrorCode::Busy => flybus::ErrorCode::QuotaExceeded,
ErrorCode::IdentityMismatch => flybus::ErrorCode::NotAuthorized,
_ => flybus::ErrorCode::RouterLost,
};
flybus::BusError::new(code, e.to_string())
}