Skip to main content

aion_server/
run.rs

1//! Run loop for the Aion workflow server: tracing initialization,
2//! configuration load, transport startup, and signal-driven graceful
3//! shutdown.
4//!
5//! This is the library entry point behind the `aion server` command. It
6//! preserves the operational contract of the former standalone
7//! `aion-server` binary: exit code 2 for configuration errors, the drain
8//! outcome's exit code on shutdown, and 130 when a second termination
9//! signal forces immediate exit.
10
11use std::{net::SocketAddr, process::ExitCode};
12
13use tokio::net::TcpListener;
14use tonic::transport::Server as TonicServer;
15use tracing::{error, info, warn};
16
17use std::sync::Arc;
18
19use crate::{
20    ServerConfig, ServerError, ServerState, api,
21    config::{CliOverrides, NamespaceMode, OutboxConfig, OutboxTransport, StoreBackend},
22    observability,
23    shutdown::{self, ShutdownOutcome},
24    worker::{
25        ActivityDispatcher, DeliveryGate, OutboxDeliveryCallback, OutboxDispatcher,
26        OutboxDispatcherConfig, OutboxReconciler, OutboxReconcilerConfig, OutboxRowDispatch,
27        ServerOutboxDeliveryCallback, WorkerOutboxDispatch,
28    },
29};
30
31/// Short TTL for the dispatcher's per-namespace placement cache (Control-Plane
32/// Phase 2, P2-P3). Kept small so an operator's `PUT /namespaces/{name}/placement`
33/// takes effect on the hot claim loop within a couple of seconds, while still
34/// collapsing a per-sweep quorum `get_namespace` into a cheap in-process lookup.
35/// A stale entry under `Prefer` only mis-prefers a worker for at most one window
36/// and self-corrects — it never affects correctness or replay.
37const PLACEMENT_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(2);
38
39/// Short TTL for the dispatcher's per-namespace quota cache (Control-Plane Phase 2,
40/// P2-Q2). Kept small so an operator raising/lowering a tenant's
41/// `max_in_flight_activities` takes effect on the hot claim loop within a couple of
42/// seconds, while still collapsing a per-sweep quorum `get_namespace` into a cheap
43/// in-process lookup. A stale entry only over- or under-admits slightly for one
44/// window and self-corrects — backpressure never drops a row, so it cannot affect
45/// correctness or replay.
46const QUOTA_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(2);
47
48/// Cadence of the ops-console quota-state broadcaster (Control-Plane Phase 2,
49/// P2-Q3). Each tick samples every registry namespace's durable Claimed-row count
50/// and cluster-wide ceiling, then pushes one `NamespaceQuotaState` per namespace
51/// onto the cluster channel, so the console badge tracks live load. Kept at 1s:
52/// brisk enough that the badge visibly ticks as work flows, throttled enough that
53/// it is never a per-row firehose (in-flight changes on every claim/settle). It is
54/// a server-side push on a timer, NOT a client poll — the dashboard rule bans the
55/// latter, not a throttled server snapshot of REAL durable state.
56const QUOTA_BROADCAST_CADENCE: std::time::Duration = std::time::Duration::from_secs(1);
57
58/// Resolved keyed-backpressure inputs for the outbox dispatcher (Control-Plane
59/// Phase 2, P2-Q2): the generous platform-default ceiling and this node's
60/// owned-shard fraction of the cluster shard space.
61#[derive(Clone, Copy, Debug)]
62struct BackpressureSettings {
63    /// The `[namespaces] max_in_flight_activities` platform default, applied to any
64    /// namespace carrying no explicit per-tenant override.
65    platform_default: u32,
66    /// This node's owned-shard fraction of the cluster's virtual shard space,
67    /// derived from `[store] owned_shards` and `[store] shard_count`.
68    fraction: crate::worker::OwnedShardFraction,
69}
70
71impl BackpressureSettings {
72    /// Derive the backpressure inputs from the merged server config.
73    ///
74    /// An empty `[store] owned_shards` means own-all (the single-node default), so
75    /// the fraction is 1 and per-node ceilings equal the cluster-wide quota. A
76    /// declared owned set enforces the proportional per-node slice
77    /// `|owned| / shard_count` (CP-Phase-2 §3.6).
78    fn from_config(config: &ServerConfig) -> Self {
79        let total = u32::try_from(config.store.shard_count).unwrap_or(u32::MAX);
80        let fraction = if config.store.owned_shards.is_empty() {
81            crate::worker::OwnedShardFraction::own_all()
82        } else {
83            let owned = u32::try_from(config.store.owned_shards.len()).unwrap_or(u32::MAX);
84            crate::worker::OwnedShardFraction::new(owned, total)
85        };
86        Self {
87            platform_default: config.namespaces.max_in_flight_activities,
88            fraction,
89        }
90    }
91}
92
93/// Owns the liminal worker listener for the server's lifetime when the outbox is
94/// commissioned over the liminal transport.
95///
96/// The aion-server HOSTS the liminal listener that remote workers connect IN to;
97/// its inner [`ServerListener`](liminal_server::server::listener::ServerListener)
98/// owns the accept worker. Held as a local in [`run_server`] across the whole
99/// serve `select!`, so it is dropped exactly at server shutdown — and the
100/// listener's own `Drop` stops the accept worker cleanly (no leaked thread, no
101/// orphaned listener). Every non-liminal boot (the default) carries the `None`
102/// guard, which holds nothing and drops to a no-op, so behaviour is unchanged.
103#[derive(Debug, Default)]
104struct OutboxWorkerListener {
105    /// Held purely for its `Drop` side-effect (stopping the accept worker on
106    /// server shutdown); never read after construction, hence the leading
107    /// underscore.
108    #[cfg(feature = "liminal-transport")]
109    _inner: Option<liminal_server::server::listener::ServerListener>,
110}
111
112/// Run the Aion workflow server until it shuts down, returning the process
113/// exit code.
114///
115/// Initializes the JSON tracing subscriber, loads and validates the merged
116/// configuration (file, environment, then `overrides`), serves the gRPC and
117/// HTTP transports, and drains gracefully after the first termination
118/// signal. Every failure is logged through tracing and mapped to the exit
119/// code contract above; the caller only has to exit with the returned code.
120pub async fn run(overrides: CliOverrides) -> ExitCode {
121    match run_server(overrides).await {
122        Ok(code) => code,
123        Err(error) => {
124            error!(%error, "aion-server failed");
125            if error.is_config() {
126                ExitCode::from(2)
127            } else {
128                ExitCode::FAILURE
129            }
130        }
131    }
132}
133
134/// The where-to-edit half of the missing `outbox.liminal_listen_address`
135/// refusal: a liminal outbox refusal must name the FILE to edit, not just the
136/// key — the operator reading it is exactly the operator who did not write
137/// the config (a scaffolded or setup-script home).
138fn liminal_address_hint(source: &crate::config::ConfigSource) -> String {
139    match source {
140        crate::config::ConfigSource::BuiltInDefaults => {
141            "set AION_OUTBOX_LIMINAL_LISTEN_ADDRESS, or add `liminal_listen_address = \
142             \"127.0.0.1:50061\"` to `[outbox]` in a config file"
143                .to_owned()
144        }
145        source => format!(
146            "add `liminal_listen_address = \"127.0.0.1:50061\"` to `[outbox]` in the {source}"
147        ),
148    }
149}
150
151async fn run_server(cli: CliOverrides) -> Result<ExitCode, ServerError> {
152    observability::tracing::init()?;
153
154    // #180: a boot that discovers no config anywhere first scaffolds
155    // `<AION_HOME>/config.toml` from the embedded template (claim-only-when-
156    // empty), then loads it — config LOAD itself stays pure and read-only.
157    let loaded = crate::config::load_or_scaffold(&cli)?;
158    loaded.resolution.ensure_private_home()?;
159    loaded.resolution.log_startup();
160    let liminal_address_hint = liminal_address_hint(&loaded.resolution.source);
161    let config = loaded.config;
162    reject_auth_without_feature(&config)?;
163    let store_backend = config.store.backend;
164    // Static shard assignment (SS-1): read the operator's pinned shard set from
165    // `[store] owned_shards`. Empty means own ALL shards (single-node default).
166    // The set is carried into `RuntimeConfig` by `into_parts` and applied to the
167    // `EngineBuilder` during state construction; surface it here so the boot
168    // banner records which shards this node serves. No election is performed.
169    let owned_shards = config.store.owned_shards.clone();
170    // Capture the outbox settings before `build` consumes `config`, so the
171    // (default-off) outbox dispatcher can be wired after state is up. The
172    // dispatcher shares the engine's already-opened libSQL store (one
173    // connection) via `state.outbox_store()`, so no store settings are needed.
174    let outbox_config = config.outbox.clone();
175    // Control-Plane Phase 2 (P2-Q2): capture the keyed-backpressure inputs — the
176    // generous platform-default ceiling and this node's owned-shard fraction —
177    // before `build` consumes `config`. On a single-node / own-all boot the fraction
178    // is 1, so per-node ceilings equal the cluster-wide quota and, with the generous
179    // default and no tenant override, the ceiling never engages (byte-identical claim).
180    let backpressure_settings = BackpressureSettings::from_config(&config);
181    // Capture the SS-5b failover supervisor knobs before `build` consumes config.
182    // Only a distributed haematite boot carries a `[store.cluster]` section; this
183    // is `None` for every single-node boot, so no supervisor is ever spawned.
184    #[cfg(feature = "haematite-backend")]
185    let cluster_config = config.store.cluster.clone();
186    // Capture the managed-worker supervision policy before `build` consumes
187    // `config`. Resolution already happened during config validation, so this
188    // cannot surprise an operator at boot; it is re-read here because the
189    // policy is COMMISSIONED onto the supervisor built into state below, and a
190    // server without the section supervises nothing.
191    let supervision_policy = config.worker_supervision.resolve()?;
192    let state = ServerState::build(config).await?;
193    reject_tls_until_supported(&state)?;
194
195    let runtime = state.runtime_config();
196    let grpc_address = runtime.listen.grpc;
197    let http_address = runtime.listen.http;
198    let workflow_packages: Vec<String> = runtime
199        .workflow_packages
200        .iter()
201        .map(|path| path.display().to_string())
202        .collect();
203    // The revision, not just the version. A crate version cannot distinguish
204    // two builds from different commits of the same version, and that is the
205    // distinction an operator needs when deciding whether a restart restores
206    // what was running or substitutes something else (#123). The endpoint
207    // answers this too, but a crashed server leaves only its log.
208    let build = crate::build_identity::BuildIdentity::current();
209    // #139: the server-resolved workspace root (the aion home's `clones/`
210    // directory) that declared bodies expand `{workspace_root}` with. Reported
211    // here so composition points (setup.sh today, the workspace verb later)
212    // READ the value from the server that will use it instead of re-deriving
213    // it. An unresolvable root is reported as exactly that — never fabricated;
214    // a placeholder-bearing dispatch will refuse terminally with this reason.
215    // The rendering itself is `WorkspaceRoot::banner_value`, pinned by its own
216    // two-case test, so the banner and the tests cannot drift apart.
217    let workspace_root = state.workspace_root().banner_value();
218    info!(
219        version = env!("CARGO_PKG_VERSION"),
220        build = %build.line(),
221        commit = build.commit,
222        grpc_address = %grpc_address,
223        http_address = %http_address,
224        default_namespace = %runtime.default_namespace,
225        namespace_mode = namespace_mode_label(&runtime.namespace.mode),
226        store_backend = store_backend_label(store_backend),
227        auth_enabled = runtime.auth.enabled,
228        deploy_enabled = runtime.deploy.enabled,
229        metrics_enabled = runtime.metrics.enabled,
230        workspace_root = %workspace_root,
231        workflow_package_count = workflow_packages.len(),
232        workflow_packages = ?workflow_packages,
233        owned_shards = ?owned_shards,
234        owns_all_shards = owned_shards.is_empty(),
235        "aion-server startup banner"
236    );
237    // #139 leg C: the assistant ships IN aion. The embedded document is
238    // installed here — after the engine has reloaded every persisted package,
239    // so the install can see what is already resident, and before the
240    // transports accept traffic, so the first caller finds it. It claims only a
241    // catalog holding no version of the assistant type; anything else is the
242    // operator's cut to make, and the outcome says so in the log either way.
243    crate::assistant::install_embedded_assistant_for_server(&state).await;
244    let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
245    // LSUB-4-1: a distributed haematite boot carries a `[store.cluster]` section.
246    // The single outbox dispatcher task is spawned in BOTH modes; the difference
247    // is only how ownership is enforced. Single-node (`None`) owns all shards by
248    // construction (`owned_shard_scope() == None`), so its claim sweeps see every
249    // row. Clustered (`Some`) relies on `claim_outbox_rows`' `owned_shard_scope()`
250    // filter — already seeded by `set_owned_shards` during `ServerState::build`,
251    // which runs before this point — so each node only ever claims rows on the
252    // shards it owns. Compute the flag here where the (feature-gated) cluster
253    // section is in scope; pass it to the gate so the boot banner records the mode.
254    #[cfg(feature = "haematite-backend")]
255    let outbox_clustered = cluster_config.is_some();
256    #[cfg(not(feature = "haematite-backend"))]
257    let outbox_clustered = false;
258    // Dormant by default: only when `outbox.enabled` is set does the
259    // non-replayed outbox dispatcher task start. With the flag off (the
260    // default) nothing here runs and server behaviour is unchanged.
261    // Hold the liminal worker listener (if any) for the server's lifetime: it is
262    // dropped at the end of `run_server`, after the serve `select!` completes, so
263    // its accept worker stops cleanly on shutdown via the listener's own `Drop`.
264    // #204/#253: rebuild the pause dispatch-hold and settle terminal
265    // workflows' stranded outbox rows BEFORE the dispatcher's first claim.
266    rebuild_outbox_boot_state(&state, &outbox_config).await;
267    let _outbox_worker_listener = maybe_spawn_outbox_dispatcher(
268        &state,
269        &outbox_config,
270        outbox_clustered,
271        backpressure_settings,
272        &shutdown_rx,
273        &liminal_address_hint,
274    )?;
275    // SS-5b: a distributed boot whose peers declare owned shards runs the cluster
276    // supervisor — automatic failover detection. A single-node boot spawns
277    // nothing here (the method returns `false`), so default behaviour is
278    // unchanged.
279    #[cfg(feature = "haematite-backend")]
280    maybe_spawn_cluster_supervisor(&state, cluster_config.as_ref(), &shutdown_rx)?;
281    // #176: the worker heartbeat expiry sweeper is ALWAYS commissioned —
282    // dead-worker detection is a liveness correctness property, not an opt-in
283    // feature. It is the production caller of `fail_expired_workers`: a worker
284    // whose stream stays open while its process wedges (stops heartbeating
285    // without disconnecting) is expired, deregistered with the provable Timeout
286    // reason, and its in-flight tasks surface as TRANSPORT losses, re-dispatched
287    // attempt-neutrally rather than charged to the action's retry budget.
288    // Cadence derives from `worker.heartbeat_window` (quarter-window, clamped to
289    // [1s, window]; the default 30s window sweeps every 7.5s) — deliberately no
290    // separate config knob. It drains on the same shutdown watch as the
291    // transports; dropping the JoinHandle only detaches the task.
292    drop(state.spawn_heartbeat_sweeper(shutdown_rx.clone()));
293    commission_worker_supervision(&state, supervision_policy).await;
294    let mut grpc = tokio::spawn(serve_grpc(state.clone(), grpc_address, shutdown_rx.clone()));
295    let mut http = tokio::spawn(serve_http(state.clone(), http_address, shutdown_rx));
296
297    let outcome = tokio::select! {
298        result = &mut grpc => {
299            transport_result("gRPC", result)?;
300            state.shutdown()?;
301            ShutdownOutcome::Clean
302        },
303        result = &mut http => {
304            transport_result("HTTP", result)?;
305            state.shutdown()?;
306            ShutdownOutcome::Clean
307        },
308        result = shutdown_signal() => {
309            result?;
310            let _receiver_count = shutdown_tx.send(true);
311            let outcome = shutdown::drain_after_first_signal(state.clone(), async {
312                let _ = shutdown_signal().await;
313            }).await?;
314            if !matches!(outcome, ShutdownOutcome::Forced) {
315                transport_result("gRPC", grpc.await)?;
316                transport_result("HTTP", http.await)?;
317            }
318            outcome
319        },
320    };
321
322    Ok(outcome.exit_code())
323}
324
325/// Install the operator's supervision policy and converge the fleet.
326///
327/// Uncommissioned is a first-class but never SILENT state: a server with no
328/// `[worker_supervision]` section supervises nothing, and every deployment that
329/// wanted to be running is named in the warning, so the gap between "the
330/// operator deployed a worker" and "nothing is running it" is never quiet.
331async fn commission_worker_supervision(
332    state: &ServerState,
333    policy: Option<crate::worker::SupervisionPolicy>,
334) {
335    let supervisor = state.worker_supervisor();
336    let Some(policy) = policy else {
337        match supervisor.report().await {
338            Ok(report) => {
339                let wanted: Vec<&str> = report
340                    .workers
341                    .iter()
342                    .filter(|worker| worker.desired == aion_store::DesiredState::Running)
343                    .map(|worker| worker.name.as_str())
344                    .collect();
345                if wanted.is_empty() {
346                    info!("managed-worker supervision is not configured; no deployment wants it");
347                } else {
348                    warn!(
349                        deployments = wanted.join(", "),
350                        remedy = crate::worker::supervisor::UNCOMMISSIONED_REMEDY,
351                        "worker deployments want to be running but supervision is not configured"
352                    );
353                }
354            }
355            Err(error) => error!(
356                %error,
357                "managed-worker supervision is not configured and the deployment records \
358                 could not be read to say what that costs"
359            ),
360        }
361        return;
362    };
363    if !supervisor.commission(policy, crate::worker::ManagedExecutable::CurrentServer) {
364        error!("managed-worker supervision was already commissioned before boot completed");
365        return;
366    }
367    match supervisor.reconcile().await {
368        Ok(0) => info!("managed-worker supervision commissioned; no deployment wants to run"),
369        Ok(supervised) => info!(supervised, "managed-worker supervision commissioned"),
370        Err(error) => error!(%error, "managed-worker fleet could not be converged at boot"),
371    }
372}
373
374fn transport_result(
375    transport: &'static str,
376    result: Result<Result<(), ServerError>, tokio::task::JoinError>,
377) -> Result<(), ServerError> {
378    match result {
379        Ok(transport_outcome) => transport_outcome,
380        Err(join_error) => Err(ServerError::Transport {
381            transport,
382            message: join_error.to_string(),
383        }),
384    }
385}
386
387async fn serve_grpc(
388    state: ServerState,
389    address: SocketAddr,
390    shutdown: tokio::sync::watch::Receiver<bool>,
391) -> Result<(), ServerError> {
392    let workflow = api::grpc::workflow_service(state.clone());
393    let worker = api::worker_grpc::worker_service(state.clone());
394    let mut router = TonicServer::builder()
395        .add_service(workflow)
396        .add_service(worker);
397    // Dark by default: the deploy service joins the listener only when the
398    // operator commissioned it; otherwise the surface answers Unimplemented.
399    if state.runtime_config().deploy.enabled {
400        router = router.add_service(api::deploy_grpc::deploy_service(state)?);
401    }
402    router
403        .serve_with_shutdown(address, shutdown_requested(shutdown))
404        .await
405        .map_err(|source| transport_bind("grpc", address, source))?;
406    Ok(())
407}
408
409async fn serve_http(
410    state: ServerState,
411    address: SocketAddr,
412    shutdown: tokio::sync::watch::Receiver<bool>,
413) -> Result<(), ServerError> {
414    let listener = TcpListener::bind(address)
415        .await
416        .map_err(|source| transport_bind("http", address, source))?;
417    axum::serve(listener, api::http::http_router(state)?)
418        .with_graceful_shutdown(shutdown_requested(shutdown))
419        .await
420        .map_err(|source| transport_bind("http", address, source))?;
421    Ok(())
422}
423
424async fn shutdown_requested(mut shutdown: tokio::sync::watch::Receiver<bool>) {
425    while !*shutdown.borrow_and_update() {
426        if shutdown.changed().await.is_err() {
427            break;
428        }
429    }
430}
431
432async fn shutdown_signal() -> Result<(), ServerError> {
433    #[cfg(unix)]
434    {
435        use tokio::signal::unix::{SignalKind, signal};
436
437        let mut terminate = signal(SignalKind::terminate())
438            .map_err(|source| signal_listener("SIGTERM", &source))?;
439        let mut interrupt =
440            signal(SignalKind::interrupt()).map_err(|source| signal_listener("SIGINT", &source))?;
441        tokio::select! {
442            _ = terminate.recv() => Ok(()),
443            _ = interrupt.recv() => Ok(()),
444        }
445    }
446
447    #[cfg(not(unix))]
448    {
449        tokio::signal::ctrl_c()
450            .await
451            .map_err(|source| signal_listener("shutdown signal", &source))
452    }
453}
454
455fn signal_listener(listener: &'static str, source: &std::io::Error) -> ServerError {
456    ServerError::SignalListener {
457        listener,
458        message: source.to_string(),
459    }
460}
461
462fn reject_auth_without_feature(config: &ServerConfig) -> Result<(), ServerError> {
463    if cfg!(not(feature = "auth")) && config.auth.enabled {
464        return Err(ServerError::Config {
465            message: "auth.enabled=true but binary compiled without auth feature".to_owned(),
466        });
467    }
468    Ok(())
469}
470
471/// Rebuild the outbox-related boot state BEFORE the dispatcher's first claim,
472/// when (and only when) the outbox is commissioned:
473///
474/// - #204: repopulate the durable pause dispatch-hold from `list_paused`, so a
475///   run paused before a restart keeps its outbox rows held (never claimed)
476///   after recovery. A run projecting `Paused` is excluded from `list_active`
477///   respawn for free; this repopulates the hold that would otherwise be empty
478///   in memory after a crash.
479/// - #253: settle terminal workflows' stranded outbox rows. A workflow that
480///   reached a durable terminal without its rows being settled (a settle-hook
481///   failure, or a crash between the terminal append and the settle) must not
482///   have those rows re-armed and redelivered after restart — that is the
483///   zombie-round incident. A sweep error is loud but non-fatal: the
484///   settle-at-terminal hook and the reconciler's liveness gate remain as
485///   repair paths, and the residual window is one bounded dispatch whose
486///   completion drops unmatched, never a re-arm loop.
487async fn rebuild_outbox_boot_state(state: &ServerState, outbox_config: &OutboxConfig) {
488    if !outbox_config.enabled {
489        return;
490    }
491    let Ok(engine) = state.engine() else {
492        return;
493    };
494    if let Err(error) = engine.rebuild_paused_runs().await {
495        warn!(%error, "failed to rebuild paused-runs dispatch hold at startup");
496    }
497    let Some(outbox_store) = state.outbox_store() else {
498        return;
499    };
500    match crate::worker::settle_terminal_outbox_rows(engine.store().as_ref(), outbox_store.as_ref())
501        .await
502    {
503        Ok(settled) if settled.is_empty() => {}
504        Ok(settled) => {
505            info!(
506                settled = settled.len(),
507                "boot sweep settled stranded outbox rows for terminal workflows"
508            );
509        }
510        Err(error) => {
511            error!(
512                %error,
513                "boot sweep failed to settle terminal workflows' outbox rows; \
514                 the reconciler liveness gate remains the backstop"
515            );
516        }
517    }
518}
519
520/// Spawn the durable-outbox fan-out dispatcher when, and only when, the
521/// operator commissioned it (`outbox.enabled = true`).
522///
523/// This is the single gate that keeps Phase 2 dormant: with the flag off (the
524/// default) the function returns immediately without spawning a task, so
525/// default server behaviour — and the live workflow dispatch path — is entirely
526/// unchanged. When commissioned, the dispatcher claims rows through the engine's
527/// own shared `Arc<LibSqlStore>` (one `libsql::Connection`), so its writes
528/// serialize with the engine's rather than contending across a second
529/// connection. The dispatcher shares the server's shutdown watch, so it drains
530/// on the same signal as the transports.
531///
532/// NOTE (Phase boundary): the spawned dispatcher dispatches claimed rows and
533/// records each row's terminal outbox state (done / retry / failed). Routing the
534/// worker completion back into workflow history through the Recorder is Phase 3
535/// and is not wired here.
536fn maybe_spawn_outbox_dispatcher(
537    state: &ServerState,
538    outbox_config: &OutboxConfig,
539    clustered: bool,
540    backpressure_settings: BackpressureSettings,
541    shutdown_rx: &tokio::sync::watch::Receiver<bool>,
542    liminal_address_hint: &str,
543) -> Result<OutboxWorkerListener, ServerError> {
544    if !outbox_config.enabled {
545        return Ok(OutboxWorkerListener::default());
546    }
547    let dispatcher_config = resolve_outbox_config(outbox_config)?;
548    // Share the engine's already-opened store: one backing connection. The
549    // dispatcher's `claim_outbox_rows` writes then serialize against the engine's
550    // `append_with_outbox` on that single connection instead of contending across
551    // a second one. Both the libSQL and the haematite backends provide an
552    // `OutboxStore` (the haematite leaf is wired as the outbox store at boot); the
553    // in-memory backend has no outbox table, so `outbox_store()` is `None` and
554    // commissioning the dispatcher against it is a configuration error (LSUB-4-2).
555    let outbox_store = state.outbox_store().ok_or_else(|| ServerError::Config {
556        message: "outbox.enabled=true requires store.backend=libsql or store.backend=haematite: \
557                  the durable outbox dispatcher claims rows from the store's outbox table, which \
558                  the in-memory store does not provide"
559            .to_owned(),
560    })?;
561    let dispatcher_builder = OutboxDispatcher::new(Arc::clone(&outbox_store), dispatcher_config);
562    let delivery_gate = dispatcher_builder.delivery_gate();
563    let engine = state.engine()?;
564    let delivery_callback: Arc<dyn OutboxDeliveryCallback> =
565        Arc::new(ServerOutboxDeliveryCallback::new(engine));
566    let (row_dispatch, worker_listener) = select_outbox_row_dispatch(
567        state,
568        outbox_config,
569        shutdown_rx,
570        delivery_gate.clone(),
571        Arc::clone(&delivery_callback),
572        liminal_address_hint,
573    )?;
574    // LSUB-2: share the engine's advisory wake so the stage seam pulses this
575    // dispatcher the instant a fan-out row commits, dispatching in ~RTT instead of
576    // up to one poll interval. The wake is always-on and free; the interval poll is
577    // untouched, so it remains the correctness backstop for any lost wake.
578    // Control-Plane Phase 2 (P2-Q2): attach per-tenant keyed backpressure so each
579    // sweep claims per-namespace, round-robin, capped at each tenant's CLAIMED-only
580    // headroom (`per_node_ceiling − claimed`). The quota cache front-runs a per-sweep
581    // quorum `get_namespace`. With the generous platform default and no tenant
582    // override the ceiling never engages, so a default deployment's claim behaviour is
583    // byte-identical to the pre-Phase-2 single unscoped claim.
584    let quota_cache = crate::worker::QuotaCache::new(
585        Arc::clone(state.namespace_store()),
586        backpressure_settings.platform_default,
587        QUOTA_CACHE_TTL,
588    );
589    let backpressure =
590        crate::worker::Backpressure::new(quota_cache.clone(), backpressure_settings.fraction);
591    let mut dispatcher = dispatcher_builder
592        .with_dispatch(row_dispatch)
593        .with_delivery_callback(delivery_callback)
594        .with_wake(state.outbox_wake())
595        .with_backpressure(backpressure);
596    // #204: attach the engine's durable pause dispatch-hold so a held (paused)
597    // run's rows are never claimed. The hold set is rebuilt from `list_paused`
598    // BEFORE this spawn (see `run_server`), so the dispatcher's first claim
599    // already excludes pre-pause rows after a restart.
600    if let Ok(engine) = state.engine() {
601        dispatcher = dispatcher.with_paused_runs(engine.paused_runs());
602    }
603    tokio::spawn(dispatcher.run(shutdown_rx.clone()));
604    // Control-Plane Phase 2 (P2-Q3): commission the ops-console quota-state
605    // broadcaster on the SAME durable stores + quota cache the dispatcher enforces
606    // against, so the console badge is a faithful window onto the live per-tenant
607    // in-flight/ceiling the backpressure caps. It shares the shutdown watch, so it
608    // drains with the dispatcher. Only spawned alongside the (default-off)
609    // dispatcher: quota state is meaningless without the outbox fan-out path, and
610    // `in_flight` is the durable Claimed outbox count that path produces.
611    let quota_broadcaster = crate::worker::QuotaBroadcaster::new(
612        Arc::clone(state.namespace_store()),
613        Arc::clone(&outbox_store),
614        quota_cache,
615        state.cluster_publisher().clone(),
616        QUOTA_BROADCAST_CADENCE,
617    );
618    tokio::spawn(quota_broadcaster.run(shutdown_rx.clone()));
619    // LSUB-4-1: the single dispatcher task is spawned in both modes. In a
620    // single-node boot it owns all shards by construction; in an active-active
621    // clustered boot it claims ONLY the shards this node owns, enforced by
622    // `claim_outbox_rows`' owned-shard scope (already seeded before this point).
623    info!(
624        clustered,
625        "outbox dispatcher commissioned (active-active per-shard ownership enforced by claim scope \
626         when clustered; single-node owns all shards)"
627    );
628    // LSUB-4-4: the stale-claim reconciler is the in-flight recovery backstop. It
629    // is only configured when BOTH reconcile knobs are set, so on a clustered boot
630    // that left them unset, owner-kill in-flight recovery latency is bounded only
631    // by re-residency replay (a survivor adopting the shard re-residents from
632    // history and re-arms via `rearm_outbox_pending`), NOT by `stale_after`. Warn
633    // so the operator knows the backstop is absent.
634    if let Some(reconciler_config) = resolve_outbox_reconciler_config(outbox_config)? {
635        // #253: the reconciler's liveness gate projects each stale candidate's
636        // workflow status from the engine's event store before any re-arm, so
637        // a terminal workflow's stranded row settles instead of redelivering.
638        let event_store = state.engine()?.store();
639        let reconciler = OutboxReconciler::new(outbox_store, event_store, reconciler_config)
640            .with_delivery_gate(delivery_gate);
641        tokio::spawn(reconciler.run(shutdown_rx.clone()));
642        info!("outbox reconciler commissioned (terminal-workflow liveness gate active)");
643    } else if clustered {
644        warn!(
645            "outbox reconciler is UNCONFIGURED on a clustered boot (outbox.reconcile_interval_ms \
646             and outbox.reconcile_stale_after_ms are both unset): in-flight recovery after an \
647             owner is killed is then bounded only by re-residency replay on the adopting node, \
648             not by a stale-claim backstop; set both knobs to bound stale-claim recovery latency"
649        );
650    }
651    Ok(worker_listener)
652}
653
654/// Spawn the SS-5b cluster supervisor when, and only when, this is a distributed
655/// haematite boot whose `[store.cluster]` declared peers with owned shards.
656///
657/// Reads the failover cadence + debounce from the cluster config (or the
658/// documented defaults), then asks the state to spawn the supervisor over its
659/// retained concrete store and live engine. With no `[store.cluster]` section —
660/// or with no peer declaring `owned_shards` — nothing is spawned and behaviour
661/// is unchanged.
662#[cfg(feature = "haematite-backend")]
663fn maybe_spawn_cluster_supervisor(
664    state: &ServerState,
665    cluster_config: Option<&crate::config::ClusterConfig>,
666    shutdown_rx: &tokio::sync::watch::Receiver<bool>,
667) -> Result<(), ServerError> {
668    let Some(cluster) = cluster_config else {
669        return Ok(());
670    };
671    let poll_interval = std::time::Duration::from_millis(
672        cluster
673            .failover_poll_interval_ms
674            .unwrap_or(crate::config::DEFAULT_FAILOVER_POLL_INTERVAL_MS),
675    );
676    let confirmations = cluster
677        .failover_confirmations
678        .unwrap_or(crate::config::DEFAULT_FAILOVER_CONFIRMATIONS);
679    let supervisor_config = crate::cluster::SupervisorConfig {
680        poll_interval,
681        confirmations,
682    };
683    let spawned = state.spawn_cluster_supervisor(supervisor_config, shutdown_rx.clone())?;
684    if spawned {
685        info!(
686            poll_interval_ms = %poll_interval.as_millis(),
687            confirmations,
688            "SS-5b cluster supervisor commissioned (automatic peer-down failover)"
689        );
690    }
691    Ok(())
692}
693
694/// Select the outbox row-dispatch sink by the configured `outbox.transport`,
695/// returning the sink plus the worker listener whose lifetime the caller must
696/// hold.
697///
698/// `grpc` (the default) builds the unchanged [`WorkerOutboxDispatch`] over the
699/// connected-worker registry and carries the empty [`OutboxWorkerListener`], so a
700/// default server is byte-identical. `liminal` builds the cross-node
701/// [`RegistryLiminalDispatch`](crate::worker::RegistryLiminalDispatch) AND stands
702/// up the liminal worker listener the aion-server hosts (returned in the guard);
703/// it is only reachable when the `liminal-transport` feature is compiled in, and
704/// selecting it without that feature is a configuration error rather than a
705/// silent fall-through to gRPC.
706fn select_outbox_row_dispatch(
707    state: &ServerState,
708    outbox_config: &OutboxConfig,
709    shutdown_rx: &tokio::sync::watch::Receiver<bool>,
710    delivery_gate: DeliveryGate,
711    delivery_callback: Arc<dyn OutboxDeliveryCallback>,
712    liminal_address_hint: &str,
713) -> Result<(Arc<dyn OutboxRowDispatch>, OutboxWorkerListener), ServerError> {
714    match outbox_config.transport {
715        OutboxTransport::Grpc => {
716            let push_dispatcher = ActivityDispatcher::new(state.worker_registry().clone())
717                .with_drain_state(state.drain_state().clone())
718                .with_completion_fences(state.pending_activities().completion_fences())
719                // Share the SAME queue-service seams the direct dispatch path
720                // uses, so a row parked on this leg reaches `GET
721                // /queues/unserved` and `describe`'s `unserved` list rather
722                // than being invisible to both.
723                .with_queue_service(
724                    state.queue_declarations().clone(),
725                    state.queue_service_state().clone(),
726                    state.runtime_config().worker.queue_service.clone(),
727                );
728            // Control-Plane Phase 2 (P2-P3): attach the short-TTL placement cache
729            // so an unpinned row in a `Prefer{L}` namespace prefers an L-labelled
730            // worker (spilling to any live worker). The cache front-runs a per-row
731            // quorum `get_namespace` on the hot claim loop; a default-`Unplaced`
732            // deployment is byte-identical (every row falls through to any-worker).
733            let placement_cache = crate::worker::PlacementCache::new(
734                Arc::clone(state.namespace_store()),
735                PLACEMENT_CACHE_TTL,
736            );
737            let dispatch: Arc<dyn OutboxRowDispatch> = Arc::new(
738                WorkerOutboxDispatch::new(push_dispatcher).with_placement_cache(placement_cache),
739            );
740            Ok((dispatch, OutboxWorkerListener::default()))
741        }
742        OutboxTransport::Liminal => build_liminal_row_dispatch(
743            state,
744            outbox_config,
745            shutdown_rx,
746            delivery_gate,
747            delivery_callback,
748            liminal_address_hint,
749        ),
750    }
751}
752
753/// Build the production liminal row-dispatch sink and host the worker listener, or
754/// fail with the missing-feature error.
755///
756/// This lifts the tested cross-node wiring (the `lsub1`/`lsub5` e2e blueprint)
757/// into the production boot. The aion-server HOSTS the liminal listener that
758/// remote workers connect IN to, so its
759/// [`ConnectionSupervisor`](liminal_server::server::connection::ConnectionSupervisor)
760/// owns each worker's connection and can push a dispatch out on it. The
761/// constructor cycle resolves the notifier <-> supervisor dependency:
762///
763/// 1. Reuse the registry already in [`ServerState`] — gRPC and liminal workers
764///    share ONE registry and the same `select_worker`, so routing is identical.
765/// 2. Build the [`LiminalConnectionNotifier`] over that registry (no supervisor
766///    yet).
767/// 3. Build the [`LiminalConnectionServices`] from the liminal listen config.
768/// 4. Build the [`ConnectionSupervisor`] WITH the services + notifier.
769/// 5. Bind the supervisor back into the notifier (must succeed).
770/// 6. Bind the [`ServerListener`] on the configured listen address — workers
771///    connect IN here.
772/// 7. Reuse the SAME completion callback the gRPC completion path installs
773///    ([`ServerOutboxDeliveryCallback`] over the live engine), so a liminal
774///    completion re-enters aion through the identical terminal-recording seam.
775/// 8. Build the [`RegistryLiminalDispatch`] over the registry + callback (it
776///    constructs the [`LiminalCompletionSource`] internally).
777///
778/// The returned listener is held by the caller for the server's lifetime; its
779/// `Drop` stops the accept worker on shutdown.
780///
781/// [`LiminalConnectionServices`]: liminal_server::server::connection::LiminalConnectionServices
782/// [`ServerListener`]: liminal_server::server::listener::ServerListener
783/// [`ServerOutboxDeliveryCallback`]: crate::worker::ServerOutboxDeliveryCallback
784/// [`LiminalCompletionSource`]: crate::worker::LiminalCompletionSource
785/// [`LiminalConnectionNotifier`]: crate::worker::LiminalConnectionNotifier
786#[cfg(feature = "liminal-transport")]
787fn build_liminal_row_dispatch(
788    state: &ServerState,
789    outbox_config: &OutboxConfig,
790    shutdown_rx: &tokio::sync::watch::Receiver<bool>,
791    delivery_gate: DeliveryGate,
792    callback: Arc<dyn OutboxDeliveryCallback>,
793    liminal_address_hint: &str,
794) -> Result<(Arc<dyn OutboxRowDispatch>, OutboxWorkerListener), ServerError> {
795    use liminal_server::config::ServerConfig as LiminalServerConfig;
796    use liminal_server::config::{LimitsConfig, ServicesConfig};
797    use liminal_server::server::connection::{ConnectionSupervisor, LiminalConnectionServices};
798    use liminal_server::server::listener::ServerListener;
799
800    use crate::worker::{LiminalConnectionNotifier, RegistryLiminalDispatch};
801
802    let listen_address = outbox_config
803        .liminal_listen_address
804        .as_ref()
805        .ok_or_else(|| ServerError::Config {
806            message: format!(
807                "outbox.transport=liminal requires outbox.liminal_listen_address (host:port \
808                 the aion-server listens on for inbound liminal worker connections); \
809                 {liminal_address_hint}"
810            ),
811        })?;
812    let listen_address: SocketAddr =
813        listen_address
814            .parse()
815            .map_err(|error| ServerError::Config {
816                message: format!(
817                    "outbox.liminal_listen_address must be a host:port socket address: {error}"
818                ),
819            })?;
820
821    // The liminal listener is the worker-connection front door only: it binds the
822    // wire listen address and serves the connection supervisor. `from_config` and
823    // `ServerListener::bind` read neither `health_listen_address` nor `channels`
824    // (the health probe is bound only by the standalone liminal server's full
825    // boot, not this embedded path), so no separate health port is bound here;
826    // it is set structurally to the listen address and never used.
827    let liminal_config = LiminalServerConfig {
828        listen_address,
829        health_listen_address: listen_address,
830        drain_timeout_ms: 30_000,
831        channels: Vec::new(),
832        routing_rules: Vec::new(),
833        persistence_path: None,
834        cluster: None,
835        // liminal 0.2.3 (H4) added an optional shared-token Connect gate. `None`
836        // keeps this embedded worker front door open at the liminal layer —
837        // identical to the pre-0.2.3 wire behavior; worker identity/authorization
838        // stays aion's job (x-aion-* registration metadata). Threading an
839        // operator-configured token through aion's outbox config is a separate
840        // feature decision, not part of the dependency alignment.
841        auth: None,
842        // liminal 0.2.4 (D2/§5): service profile + operational bounds. Defaults =
843        // full profile + the certifying-pair-signed caps — byte-equivalent to the
844        // 0.2.3 behaviour this embedded front door always had. A worker-front-door
845        // profile election here is a future feature decision, not this migration.
846        services: ServicesConfig::default(),
847        limits: LimitsConfig::default(),
848        // liminal 0.3.0 (LP-WS-TRANSPORT R1 / LP Part B): optional WebSocket
849        // acceptor and participant lifecycle activation. `None` for both starts
850        // no WebSocket listener and leaves the participant capability disabled —
851        // documented as byte-identical to the pre-0.3.0 build. Electing either
852        // for this embedded worker front door is a feature decision, not part of
853        // the dependency alignment.
854        websocket: None,
855        participant: None,
856    };
857
858    // (1) Reuse the registry already in ServerState: gRPC + liminal workers share
859    // ONE registry and the same `select_worker`.
860    let registry = state.worker_registry().clone();
861    // (2) Notifier over that registry (supervisor bound after it is built), with the
862    // NOI-5b transcript tap: a worker's observability publishes on the reserved
863    // channel drain into the SAME transcript sequencer the transcript socket serves,
864    // so a live agent's transcript is persisted + fanned out. (Captures the current
865    // runtime handle to bridge the sync connection callback onto the async append.)
866    let notifier = Arc::new(
867        LiminalConnectionNotifier::new(registry.clone())
868            .with_contract_catalog(state.engine()?)
869            .with_transcript_publisher(state.transcript_publisher().clone())
870            // The SAME per-task liveness tracker the engine-seam bridge tracks
871            // into: a liminal worker's automatic liveness beats refresh it, so
872            // the #176 expiry sweeper never falsely expires a healthy liminal
873            // worker running an activity longer than the heartbeat window.
874            .with_heartbeat_tracker(state.heartbeat_tracker().clone()),
875    );
876    // (3) Connection services from the liminal listen config.
877    let services = Arc::new(
878        LiminalConnectionServices::from_config(&liminal_config).map_err(|error| {
879            ServerError::Config {
880                message: format!("liminal connection services build failed: {error}"),
881            }
882        })?,
883    );
884    // (4) Supervisor WITH the services + notifier (the cycle's forward edge).
885    let supervisor = ConnectionSupervisor::with_services_and_notifier(services, notifier.clone())
886        .map_err(|error| ServerError::Config {
887        message: format!("liminal connection supervisor build failed: {error}"),
888    })?;
889    // (5) Bind the supervisor back into the notifier (the cycle's back edge); a
890    // failure here is a wiring bug, surfaced rather than silently ignored.
891    if !notifier.bind_supervisor(supervisor.clone()) {
892        return Err(ServerError::Config {
893            message: "liminal notifier supervisor handle was already bound during boot".to_owned(),
894        });
895    }
896    // (5b) Commission the connection dead-man switch over the SAME notifier. It
897    // pings every connected worker on a derived quarter-window cadence: the
898    // answers keep a healthy IDLE connection's lease alive (so the idle expiry
899    // cannot fire on a live worker), and the pings themselves are what a worker
900    // measures silence against (so a wedged half-open socket becomes a declared,
901    // logged death on the worker side instead of an unbounded blind wait). Not
902    // opt-in: liveness detection is a correctness property of this transport.
903    // The handle is detached — dropping a tokio `JoinHandle` never cancels the
904    // task — exactly as the heartbeat sweeper is spawned.
905    drop(state.spawn_liminal_liveness_probe(notifier.clone(), shutdown_rx.clone()));
906    // (6) Bind the listener on the configured address — workers connect IN here.
907    let listener =
908        ServerListener::bind(&liminal_config, supervisor).map_err(|error| ServerError::Config {
909            message: format!("liminal worker listener failed to bind {listen_address}: {error}"),
910        })?;
911    // (7) Reuse the SAME completion callback the gRPC completion path uses, over
912    // the live engine, so a liminal completion re-enters aion through the
913    // identical terminal-recording seam (`record_fan_out_completion`).
914    // (8) The registry-backed dispatch builds its LiminalCompletionSource from the
915    // shared callback internally. Attach the SAME short-TTL placement cache the
916    // gRPC arm installs (Control-Plane Phase 2, P2-P3), so an unpinned row in a
917    // `Prefer{L}` namespace prefers an L-labelled worker (spilling to any live
918    // worker) on the cross-node liminal transport too — the cluster-failover
919    // demo behaviour. A default-`Unplaced` deployment is byte-identical.
920    let placement_cache = crate::worker::PlacementCache::new(
921        Arc::clone(state.namespace_store()),
922        PLACEMENT_CACHE_TTL,
923    );
924    // NOI-6: install the SAME attempt-owner back-index the server's intervention
925    // router resolves through, so each dispatched agent attempt binds its owning
926    // worker and a pushed command reaches the worker this dispatcher sent it to.
927    let dispatch: Arc<dyn OutboxRowDispatch> = Arc::new(
928        RegistryLiminalDispatch::new(registry, callback, delivery_gate)
929            .with_placement_cache(placement_cache)
930            .with_attempt_owners(state.attempt_owners().clone()),
931    );
932
933    info!(
934        listen_address = %listen_address,
935        "liminal outbox worker listener commissioned (remote workers connect in and self-register)"
936    );
937    Ok((
938        dispatch,
939        OutboxWorkerListener {
940            _inner: Some(listener),
941        },
942    ))
943}
944
945/// Feature-off stub: selecting the liminal transport without the
946/// `liminal-transport` feature is a configuration error, never a silent
947/// fall-through to gRPC.
948#[cfg(not(feature = "liminal-transport"))]
949fn build_liminal_row_dispatch(
950    _state: &ServerState,
951    _outbox_config: &OutboxConfig,
952    _shutdown_rx: &tokio::sync::watch::Receiver<bool>,
953    _delivery_gate: DeliveryGate,
954    _delivery_callback: Arc<dyn OutboxDeliveryCallback>,
955    _liminal_address_hint: &str,
956) -> Result<(Arc<dyn OutboxRowDispatch>, OutboxWorkerListener), ServerError> {
957    Err(ServerError::Config {
958        message: "outbox.transport=liminal requires the aion-server `liminal-transport` \
959                  Cargo feature, which is not enabled in this build"
960            .to_owned(),
961    })
962}
963
964/// Resolve the validated, all-present outbox knobs into the dispatcher's
965/// non-optional config. Validation already guaranteed each value is set and in
966/// range when `outbox.enabled` is true, so an absent value here is a defensive
967/// configuration error, not a default to invent.
968fn resolve_outbox_config(outbox: &OutboxConfig) -> Result<OutboxDispatcherConfig, ServerError> {
969    let poll_interval_ms = outbox.poll_interval_ms.ok_or_else(|| ServerError::Config {
970        message: crate::config::OUTBOX_POLL_INTERVAL_REQUIRED.to_owned(),
971    })?;
972    let batch_size = outbox.batch_size.ok_or_else(|| ServerError::Config {
973        message: crate::config::OUTBOX_BATCH_SIZE_REQUIRED.to_owned(),
974    })?;
975    let max_attempts = outbox.max_attempts.ok_or_else(|| ServerError::Config {
976        message: crate::config::OUTBOX_MAX_ATTEMPTS_REQUIRED.to_owned(),
977    })?;
978    let backoff_base_ms = outbox.backoff_base_ms.ok_or_else(|| ServerError::Config {
979        message: crate::config::OUTBOX_BACKOFF_BASE_REQUIRED.to_owned(),
980    })?;
981    let backoff_multiplier = outbox
982        .backoff_multiplier
983        .ok_or_else(|| ServerError::Config {
984            message: crate::config::OUTBOX_BACKOFF_MULTIPLIER_REQUIRED.to_owned(),
985        })?;
986    let backoff_max_ms = outbox.backoff_max_ms.ok_or_else(|| ServerError::Config {
987        message: crate::config::OUTBOX_BACKOFF_MAX_REQUIRED.to_owned(),
988    })?;
989    Ok(OutboxDispatcherConfig {
990        poll_interval: std::time::Duration::from_millis(poll_interval_ms),
991        batch_size,
992        max_attempts,
993        backoff_base: std::time::Duration::from_millis(backoff_base_ms),
994        backoff_multiplier,
995        backoff_max: std::time::Duration::from_millis(backoff_max_ms),
996    })
997}
998
999fn resolve_outbox_reconciler_config(
1000    outbox: &OutboxConfig,
1001) -> Result<Option<OutboxReconcilerConfig>, ServerError> {
1002    let (Some(interval_ms), Some(stale_after_ms)) = (
1003        outbox.reconcile_interval_ms,
1004        outbox.reconcile_stale_after_ms,
1005    ) else {
1006        return Ok(None);
1007    };
1008    let batch_size = outbox.batch_size.ok_or_else(|| ServerError::Config {
1009        message: crate::config::OUTBOX_BATCH_SIZE_REQUIRED.to_owned(),
1010    })?;
1011    Ok(Some(OutboxReconcilerConfig {
1012        interval: std::time::Duration::from_millis(interval_ms),
1013        stale_after: std::time::Duration::from_millis(stale_after_ms),
1014        batch_size,
1015    }))
1016}
1017
1018fn reject_tls_until_supported(state: &ServerState) -> Result<(), ServerError> {
1019    if state.runtime_config().tls.is_some() {
1020        return Err(ServerError::Config {
1021            message: "configured TLS material cannot be served until transport TLS is wired"
1022                .to_owned(),
1023        });
1024    }
1025    Ok(())
1026}
1027
1028fn store_backend_label(backend: StoreBackend) -> &'static str {
1029    match backend {
1030        StoreBackend::Memory => "memory",
1031        StoreBackend::LibSql => "libsql",
1032        StoreBackend::Haematite => "haematite",
1033    }
1034}
1035
1036fn namespace_mode_label(mode: &NamespaceMode) -> &'static str {
1037    match mode {
1038        NamespaceMode::SharedEngine => "SharedEngine",
1039        NamespaceMode::SingleTenant { .. } => "SingleTenant",
1040    }
1041}
1042
1043fn transport_bind<E>(transport: &'static str, address: SocketAddr, source: E) -> ServerError
1044where
1045    E: std::error::Error,
1046{
1047    ServerError::TransportBind {
1048        transport,
1049        address,
1050        message: source.to_string(),
1051    }
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056    #![allow(clippy::expect_used)]
1057
1058    use super::{
1059        BackpressureSettings, OutboxConfig, OutboxTransport, maybe_spawn_outbox_dispatcher,
1060        resolve_outbox_reconciler_config,
1061    };
1062    use crate::ServerState;
1063    use crate::config::RuntimeConfig;
1064    use aion_store::InMemoryStore;
1065    use std::net::SocketAddr;
1066    use std::time::Duration;
1067
1068    /// Own-all, generous-default backpressure settings for the gate tests (the
1069    /// single-node default: fraction 1, so the ceiling never engages).
1070    fn test_backpressure_settings() -> BackpressureSettings {
1071        BackpressureSettings {
1072            platform_default: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
1073            fraction: crate::worker::OwnedShardFraction::own_all(),
1074        }
1075    }
1076
1077    /// A minimal `RuntimeConfig` for building an in-memory `ServerState` in unit
1078    /// tests (mirrors `state.rs`'s test `runtime_config`).
1079    fn runtime_config() -> RuntimeConfig {
1080        use crate::config::{
1081            AuthConfig, AuthoringConfig, DeployConfig, DevConfig, ListenConfig, MetricsConfig,
1082            NamespaceConfig, NamespaceMode, OpsConsoleAssetSource, OpsConsoleConfig,
1083            WebSocketConfig, WorkerConfig,
1084        };
1085        RuntimeConfig {
1086            listen: ListenConfig {
1087                grpc: SocketAddr::from(([127, 0, 0, 1], 50051)),
1088                http: SocketAddr::from(([127, 0, 0, 1], 8080)),
1089            },
1090            tls: None,
1091            auth: AuthConfig {
1092                enabled: false,
1093                jwks_url: None,
1094                jwks_refresh_seconds: 300,
1095            },
1096            ops_console: OpsConsoleConfig {
1097                source: OpsConsoleAssetSource::Embedded,
1098            },
1099            namespace: NamespaceConfig {
1100                mode: NamespaceMode::SharedEngine,
1101            },
1102            worker: WorkerConfig {
1103                heartbeat_window: Duration::from_secs(30),
1104                ..WorkerConfig::default()
1105            },
1106            websocket: WebSocketConfig {
1107                outbound_buffer_bound: 32,
1108                event_broadcast_capacity: Some(64),
1109                cluster_broadcast_capacity: Some(64),
1110            },
1111            workflow_packages: Vec::new(),
1112            deploy: DeployConfig::default(),
1113            authoring: AuthoringConfig::default(),
1114            dev: DevConfig::default(),
1115            outbox: OutboxConfig::default(),
1116            observability: crate::config::ObservabilityConfig::default(),
1117            mcp: crate::config::ResolvedMcpConfig::default(),
1118            scheduler_threads: 1,
1119            query_timeout: Some(Duration::from_secs(10)),
1120            default_namespace: "default".to_owned(),
1121            auto_create: crate::config::AutoCreate::Open,
1122            max_in_flight_activities: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
1123            drain_timeout: Duration::from_secs(30),
1124            metrics: MetricsConfig { enabled: true },
1125            owned_shards: Vec::new(),
1126            cors_allowed_origins: Vec::new(),
1127        }
1128    }
1129
1130    /// An `OutboxConfig` with `enabled = true` and every required knob present, so
1131    /// the only remaining gate is the store-backend / outbox-table availability.
1132    fn enabled_outbox_config() -> OutboxConfig {
1133        OutboxConfig {
1134            enabled: true,
1135            poll_interval_ms: Some(250),
1136            batch_size: Some(64),
1137            max_attempts: Some(5),
1138            backoff_base_ms: Some(100),
1139            backoff_multiplier: Some(2),
1140            backoff_max_ms: Some(30_000),
1141            reconcile_interval_ms: None,
1142            reconcile_stale_after_ms: None,
1143            transport: OutboxTransport::Grpc,
1144            liminal_listen_address: None,
1145        }
1146    }
1147
1148    /// LSUB-4-2 / LSUB-4-6 (Memory-backend guard): commissioning the outbox
1149    /// dispatcher against the in-memory backend (which has no outbox table, so
1150    /// `outbox_store()` is `None`) is a configuration error, and the message names
1151    /// BOTH supported backends (libsql / haematite), not just libsql.
1152    #[tokio::test]
1153    async fn outbox_enabled_on_memory_backend_is_a_config_error() {
1154        let state = ServerState::build_with_store(InMemoryStore::default(), runtime_config())
1155            .await
1156            .expect("build in-memory state");
1157        let (_tx, rx) = tokio::sync::watch::channel(false);
1158        let error = maybe_spawn_outbox_dispatcher(
1159            &state,
1160            &enabled_outbox_config(),
1161            false,
1162            test_backpressure_settings(),
1163            &rx,
1164            "set outbox.liminal_listen_address in the test config",
1165        )
1166        .expect_err("outbox.enabled on the memory backend must be a config error");
1167        assert!(
1168            error.is_config(),
1169            "memory-backend outbox error must be Config"
1170        );
1171        let message = error.to_string();
1172        assert!(
1173            message.contains("libsql") && message.contains("haematite"),
1174            "corrected message must name both supported backends, got: {message}"
1175        );
1176    }
1177
1178    /// LSUB-4-1 (Fork-B fast path): with the outbox disabled (the default), the
1179    /// gate is a no-op even on a memory backend — nothing is spawned and no error
1180    /// is produced, so a default single-node boot is unchanged.
1181    #[tokio::test]
1182    async fn disabled_outbox_is_a_noop_on_any_backend() {
1183        let state = ServerState::build_with_store(InMemoryStore::default(), runtime_config())
1184            .await
1185            .expect("build in-memory state");
1186        let (_tx, rx) = tokio::sync::watch::channel(false);
1187        maybe_spawn_outbox_dispatcher(
1188            &state,
1189            &OutboxConfig::default(),
1190            false,
1191            test_backpressure_settings(),
1192            &rx,
1193            "set outbox.liminal_listen_address in the test config",
1194        )
1195        .expect("disabled outbox gate must be an infallible no-op");
1196    }
1197
1198    /// LSUB-4-4: the reconciler config resolves to `None` unless BOTH knobs are
1199    /// set — the condition under which the clustered-boot WARN fires.
1200    #[test]
1201    fn reconciler_config_absent_unless_both_knobs_set() {
1202        let mut config = enabled_outbox_config();
1203        // Neither knob: absent.
1204        assert!(
1205            resolve_outbox_reconciler_config(&config)
1206                .expect("resolve")
1207                .is_none()
1208        );
1209        // Only interval: still absent (the silent-backstop-absent default).
1210        config.reconcile_interval_ms = Some(1_000);
1211        assert!(
1212            resolve_outbox_reconciler_config(&config)
1213                .expect("resolve")
1214                .is_none()
1215        );
1216        // Both set: present.
1217        config.reconcile_stale_after_ms = Some(60_000);
1218        assert!(
1219            resolve_outbox_reconciler_config(&config)
1220                .expect("resolve")
1221                .is_some()
1222        );
1223    }
1224
1225    /// LSUB-PROD (13-6): the liminal transport requires `liminal_listen_address`.
1226    /// Commissioning the dispatcher with `transport = liminal` but no listen
1227    /// address is a configuration error naming the missing knob, rather than a
1228    /// panic or a silent fall-through to gRPC. Built over the libSQL backend (so
1229    /// the outbox-store gate passes and the missing-address check is actually
1230    /// reached). (Feature-gated: the liminal arm of `build_liminal_row_dispatch`
1231    /// only exists with `liminal-transport` on; in a feature-off build the same
1232    /// selection is the missing-feature error instead, covered by the type system
1233    /// rather than this test.)
1234    // Also gated on `libsql-backend`: it boots a real libSQL-backed `ServerState`
1235    // to obtain an outbox-bearing store, and the libSQL connect path is now an
1236    // opt-in feature. The listen-address guard itself is backend-agnostic.
1237    #[cfg(all(feature = "liminal-transport", feature = "libsql-backend"))]
1238    #[tokio::test]
1239    async fn liminal_transport_requires_listen_address() {
1240        use crate::config::{
1241            RuntimeSection, ServerConfig, StoreBackend, StoreConfig, WebSocketConfig,
1242        };
1243
1244        let db_path = std::env::temp_dir().join(format!(
1245            "aion-lsub-prod-listen-guard-{}-{}.db",
1246            std::process::id(),
1247            std::time::SystemTime::now()
1248                .duration_since(std::time::UNIX_EPOCH)
1249                .map(|elapsed| elapsed.as_nanos())
1250                .unwrap_or_default()
1251        ));
1252        let mut outbox = enabled_outbox_config();
1253        outbox.transport = OutboxTransport::Liminal;
1254        outbox.liminal_listen_address = None;
1255        let config = ServerConfig {
1256            store: StoreConfig {
1257                backend: StoreBackend::LibSql,
1258                url: Some(db_path.to_string_lossy().into_owned()),
1259                ..StoreConfig::default()
1260            },
1261            runtime: RuntimeSection {
1262                scheduler_threads: 1,
1263                query_timeout_ms: Some(10_000),
1264            },
1265            websocket: WebSocketConfig {
1266                outbound_buffer_bound: 32,
1267                event_broadcast_capacity: Some(64),
1268                cluster_broadcast_capacity: Some(64),
1269            },
1270            outbox: outbox.clone(),
1271            ..ServerConfig::default()
1272        };
1273        let state = ServerState::build(config)
1274            .await
1275            .expect("build libsql state");
1276        let (_tx, rx) = tokio::sync::watch::channel(false);
1277
1278        let error = maybe_spawn_outbox_dispatcher(
1279            &state,
1280            &outbox,
1281            false,
1282            test_backpressure_settings(),
1283            &rx,
1284            "add `liminal_listen_address = \"127.0.0.1:50061\"` to `[outbox]` in the test config",
1285        )
1286        .expect_err("liminal transport without a listen address must be a config error");
1287        assert!(
1288            error.is_config(),
1289            "missing-listen-address error must be Config"
1290        );
1291        assert!(
1292            error.to_string().contains("liminal_listen_address"),
1293            "error must name the missing knob, got: {error}"
1294        );
1295        // #180 review MAJ-4: the refusal must carry the caller's threaded
1296        // where-to-edit hint, so the production message names the resolved
1297        // config FILE, not just the key.
1298        assert!(
1299            error.to_string().contains("in the test config"),
1300            "error must carry the threaded config-location hint, got: {error}"
1301        );
1302    }
1303}
1304
1305/// LSUB-PROD (13-6): production-boot cross-node round-trip over the REAL wiring.
1306///
1307/// This is the proof that the production boot now does the full round-trip the
1308/// retired stub could not. It drives the EXACT production commissioning function
1309/// `run_server` calls — [`maybe_spawn_outbox_dispatcher`] — over a real
1310/// [`ServerState`] built with `outbox.enabled`, `transport = liminal`, and a
1311/// `liminal_listen_address`. That function lifts the full push wiring
1312/// (`build_liminal_row_dispatch`): it hosts the liminal worker listener, builds
1313/// [`RegistryLiminalDispatch`](crate::worker::RegistryLiminalDispatch) over the
1314/// SAME registry the gRPC path uses and the SAME
1315/// [`ServerOutboxDeliveryCallback`](crate::worker::ServerOutboxDeliveryCallback)
1316/// (over the live engine), and spawns the real [`OutboxDispatcher`].
1317///
1318/// A REAL remote [`LiminalActivityWorker`](aion_worker::LiminalActivityWorker)
1319/// connects IN to the listener and self-registers in-band. A `collect_four`
1320/// fan-out is started over the REAL HTTP transport, which stages four pending
1321/// outbox rows; the production-wired dispatcher claims and pushes each to the
1322/// worker, the worker executes it, and its completion re-enters aion through the
1323/// production engine callback — `record_fan_out_completion` — driving the
1324/// workflow to a recorded terminal. The proof asserts BOTH: the worker observably
1325/// executed the activities, AND the terminals were recorded in history (four
1326/// `ActivityCompleted` + one `WorkflowCompleted`), which the stub's
1327/// publish-and-mark-done path never achieved.
1328// Also gated on `libsql-backend`: this production-boot round-trip stands up a
1329// real libSQL-backed server (the durable outbox path it exercises), and the
1330// libSQL connect path is now an opt-in feature.
1331#[cfg(all(test, feature = "liminal-transport", feature = "libsql-backend"))]
1332mod lsub_prod_xnode_e2e {
1333    #![allow(clippy::expect_used)]
1334
1335    use std::net::SocketAddr;
1336    use std::path::PathBuf;
1337    use std::sync::Arc;
1338    use std::sync::atomic::{AtomicUsize, Ordering};
1339    use std::time::{Duration, Instant};
1340
1341    use aion_core::Event;
1342    use aion_package::{
1343        ActionContract, BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, Manifest,
1344        ManifestVersion, PackageBuilder, PackageContract, WorkerContract,
1345    };
1346    use aion_store::ReadableEventStore;
1347    use aion_store_libsql::LibSqlStore;
1348    use aion_worker::{ActivityRegistry, LiminalActivityWorker, WorkerConfig};
1349    use axum::body;
1350    use axum::http::{Request, StatusCode};
1351    use serde_json::json;
1352    use tower::ServiceExt;
1353
1354    use super::{BackpressureSettings, maybe_spawn_outbox_dispatcher};
1355    use crate::ServerState;
1356    use crate::api::http::http_router;
1357    use crate::config::{
1358        OutboxConfig, OutboxTransport, RuntimeSection, ServerConfig, StoreBackend, StoreConfig,
1359        WebSocketConfig,
1360    };
1361
1362    type TestError = Box<dyn std::error::Error + Send + Sync>;
1363
1364    /// The `collect_four` fixture passes each member the JSON string `"in"` as
1365    /// activity input, so the worker handler decodes a [`String`], not a struct.
1366    type FanInput = String;
1367
1368    const NAMESPACE: &str = "default";
1369    const TASK_QUEUE: &str = "default";
1370    const OUTBOX_MODULE: &str = "aion_outbox_fixture";
1371    const OUTBOX_BEAM: &[u8] = include_bytes!("../tests/fixtures/aion_outbox_fixture.beam");
1372    const OUTBOX_SOURCE: &[u8] = include_bytes!("../tests/fixtures/aion_outbox_fixture.erl");
1373    const FAN_OUT: usize = 4;
1374    const FAN_ACTIVITY_TYPES: [&str; FAN_OUT] = ["fan:0", "fan:1", "fan:2", "fan:3"];
1375    const POLL_DEADLINE: Duration = Duration::from_secs(20);
1376
1377    fn test_error(message: impl std::fmt::Display) -> TestError {
1378        message.to_string().into()
1379    }
1380
1381    /// Reserve a loopback port and return it: the liminal listener binds this exact
1382    /// address (the production path binds the configured `liminal_listen_address`,
1383    /// so the test must commit to a concrete port the worker can also dial).
1384    fn reserve_loopback_port() -> Result<SocketAddr, TestError> {
1385        let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(test_error)?;
1386        let address = listener.local_addr().map_err(test_error)?;
1387        drop(listener);
1388        Ok(address)
1389    }
1390
1391    /// The fixture's queue-scoped `.v4` contract: the four `fan:N` activities
1392    /// `collect_four` schedules, declared on the queue its worker actually polls.
1393    ///
1394    /// Why the archive cannot just carry the manifest-derived record: by design
1395    /// `PackageContract::from_manifest` "never invents a queue", so a manifest's
1396    /// bare activity names land in `unscoped_activities` — and this server boots
1397    /// queue-routed, where an unscoped catalog is a terminal
1398    /// `NO_QUEUE_DECLARATION` at start admission
1399    /// (`aion::lifecycle::start_admission`). That refusal is EARNED: an unserved
1400    /// queue would otherwise wait silently forever. So the derived record is
1401    /// amended rather than bypassed — the same four names move out of
1402    /// `unscoped_activities` and onto the queue that serves them — and the
1403    /// package still loads through the production boot path with the `.v4`
1404    /// identity `PackageBuilder` stamps over this exact contract.
1405    ///
1406    /// The action schemas come from the SAME generator the worker's typed
1407    /// registry uses, for the SAME Rust types: `collect_four` passes each member
1408    /// the JSON string `"in"` and the handler returns a [`String`]. Deriving both
1409    /// sides from `activity_descriptor::<FanInput, String>` means the package's
1410    /// declaration and the worker's advertisement cannot drift apart, so
1411    /// registration admission (`WORKER_CONTRACT_MISMATCH`) compares two schemas
1412    /// with one source.
1413    fn fixture_contract(manifest: &Manifest) -> Result<PackageContract, TestError> {
1414        let mut actions = Vec::with_capacity(FAN_ACTIVITY_TYPES.len());
1415        for activity_type in FAN_ACTIVITY_TYPES {
1416            let descriptor = aion_worker::activity_descriptor::<FanInput, String>(activity_type)
1417                .map_err(test_error)?;
1418            actions.push(ActionContract {
1419                name: descriptor.name,
1420                input_schema: descriptor.input_schema,
1421                output_schema: descriptor.output_schema,
1422                node: None,
1423                timeout: None,
1424                retry: None,
1425                advisory: false,
1426                // A typed `String -> String` handler serves these, not an agent
1427                // harness — the fan fixture's shape merely coincides with an
1428                // agent seam's, and marking it would route it somewhere no
1429                // handler is.
1430                agent: false,
1431                // A connected worker serves this fixture's queue, so the
1432                // declaration carries no body of its own.
1433                body: None,
1434            });
1435        }
1436        let mut contract = PackageContract::from_manifest(manifest);
1437        contract.workers = vec![WorkerContract {
1438            task_queue: TASK_QUEUE.to_owned(),
1439            actions,
1440        }];
1441        contract.unscoped_activities.clear();
1442        Ok(contract)
1443    }
1444
1445    /// Build the `collect_four` package on disk so the production state-build path
1446    /// loads it exactly as it loads operator-supplied `workflow_packages`.
1447    fn write_package_archive(dir: &std::path::Path) -> Result<PathBuf, TestError> {
1448        let beams =
1449            BeamSet::new(vec![BeamModule::new(OUTBOX_MODULE, OUTBOX_BEAM)]).map_err(test_error)?;
1450        let manifest = Manifest {
1451            entry_module: OUTBOX_MODULE.to_owned(),
1452            entry_function: "collect_four".to_owned(),
1453            input_schema: json!({ "type": "object" }),
1454            output_schema: json!({}),
1455            timeout: Some(Duration::from_secs(30)),
1456            // The four ordinals `collect_four` actually fans out. This manifest
1457            // used to name one invented activity, `fixture_activity`, that the
1458            // fixture never schedules and no worker ever served.
1459            activities: FAN_ACTIVITY_TYPES
1460                .iter()
1461                .map(|activity_type| DeclaredActivity {
1462                    activity_type: (*activity_type).to_owned(),
1463                })
1464                .collect(),
1465            version: ManifestVersion::new("stamped-by-builder"),
1466            format_version: CURRENT_FORMAT_VERSION,
1467            additional_workflows: Vec::new(),
1468        };
1469        let contract = fixture_contract(&manifest)?;
1470        let archive =
1471            PackageBuilder::with_source(manifest, beams, [(OUTBOX_MODULE, OUTBOX_SOURCE.to_vec())])
1472                .with_contract(contract)
1473                .write_to_bytes()
1474                .map_err(test_error)?;
1475        let path = dir.join("collect_four.aion");
1476        std::fs::write(&path, archive).map_err(test_error)?;
1477        Ok(path)
1478    }
1479
1480    /// A production-shaped `ServerConfig`: the libSQL backend (so the boot store
1481    /// path shares the leaf as the dispatcher's outbox store, exactly as
1482    /// `ServerState::build` does in production), `outbox.enabled`,
1483    /// `transport = liminal`, the reserved `liminal_listen_address`, and the
1484    /// `collect_four` package. Built through `ServerState::build` (not
1485    /// `build_with_store`), so this is the real boot store seam, not a test stand-in.
1486    fn server_config(
1487        db_path: &std::path::Path,
1488        package_path: PathBuf,
1489        listen_address: SocketAddr,
1490    ) -> ServerConfig {
1491        ServerConfig {
1492            store: StoreConfig {
1493                backend: StoreBackend::LibSql,
1494                url: Some(db_path.to_string_lossy().into_owned()),
1495                ..StoreConfig::default()
1496            },
1497            runtime: RuntimeSection {
1498                scheduler_threads: 1,
1499                query_timeout_ms: Some(10_000),
1500            },
1501            websocket: WebSocketConfig {
1502                outbound_buffer_bound: 32,
1503                event_broadcast_capacity: Some(64),
1504                cluster_broadcast_capacity: Some(64),
1505            },
1506            workflow_packages: vec![package_path],
1507            outbox: OutboxConfig {
1508                enabled: true,
1509                poll_interval_ms: Some(20),
1510                batch_size: Some(16),
1511                max_attempts: Some(5),
1512                backoff_base_ms: Some(50),
1513                backoff_multiplier: Some(2),
1514                backoff_max_ms: Some(1_000),
1515                reconcile_interval_ms: None,
1516                reconcile_stale_after_ms: None,
1517                transport: OutboxTransport::Liminal,
1518                liminal_listen_address: Some(listen_address.to_string()),
1519            },
1520            ..ServerConfig::default()
1521        }
1522    }
1523
1524    /// The remote worker self-describes for the fixture's pool `(default, default)`
1525    /// and registers a handler for every `fan:N` activity type, counting executions
1526    /// so the test proves it genuinely ran the pushed dispatches.
1527    fn worker_config() -> Result<WorkerConfig, TestError> {
1528        WorkerConfig::builder()
1529            .endpoint("unused-direct-address")
1530            .namespace(NAMESPACE)
1531            .task_queue(TASK_QUEUE)
1532            .identity("lsub-prod-worker")
1533            .max_concurrency(4)
1534            .reconnect_initial_backoff(Duration::from_millis(5))
1535            .reconnect_max_backoff(Duration::from_millis(20))
1536            .reconnect_max_attempts(3)
1537            .build()
1538            .map_err(test_error)
1539    }
1540
1541    fn worker_registry(executions: &Arc<AtomicUsize>) -> Result<Arc<ActivityRegistry>, TestError> {
1542        let mut registry = ActivityRegistry::new();
1543        for activity_type in FAN_ACTIVITY_TYPES {
1544            let executions = Arc::clone(executions);
1545            // `register_activity_with_contract`, not `register_activity`: the
1546            // bare form registers a handler with NO descriptor, so the worker
1547            // advertises four names and zero typed contracts, and admission —
1548            // which compares CONTRACTS — refuses the registration outright
1549            // (`WORKER_CONTRACT_MISMATCH`). Deriving the advertisement from
1550            // `<FanInput, String>` is what makes it the same source the
1551            // package's `fixture_contract` declares from, so the two sides
1552            // cannot drift.
1553            registry = registry
1554                .register_activity_with_contract(
1555                    activity_type,
1556                    move |_input: FanInput, _context| {
1557                        let executions = Arc::clone(&executions);
1558                        Box::pin(async move {
1559                            executions.fetch_add(1, Ordering::SeqCst);
1560                            Ok(activity_type.to_owned())
1561                        })
1562                    },
1563                )
1564                .map_err(test_error)?;
1565        }
1566        Ok(Arc::new(registry))
1567    }
1568
1569    /// Spawns the remote worker on its own OS thread with a current-thread runtime
1570    /// (the push receive is blocking), connecting IN to the production listener.
1571    struct WorkerThread {
1572        stop: Arc<std::sync::atomic::AtomicBool>,
1573        handle: Option<std::thread::JoinHandle<()>>,
1574    }
1575
1576    impl WorkerThread {
1577        fn spawn(address: String, config: WorkerConfig, registry: Arc<ActivityRegistry>) -> Self {
1578            let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
1579            let thread_stop = Arc::clone(&stop);
1580            let handle = std::thread::spawn(move || {
1581                let runtime = match tokio::runtime::Builder::new_current_thread()
1582                    .enable_all()
1583                    .build()
1584                {
1585                    Ok(runtime) => runtime,
1586                    Err(error) => {
1587                        eprintln!("worker runtime build failed: {error}");
1588                        return;
1589                    }
1590                };
1591                runtime.block_on(async move {
1592                    let worker = match LiminalActivityWorker::connect(&address, &config, registry) {
1593                        Ok(worker) => worker,
1594                        Err(error) => {
1595                            eprintln!("worker connect failed: {error}");
1596                            return;
1597                        }
1598                    };
1599                    if let Err(error) = worker
1600                        .serve_until(|| thread_stop.load(Ordering::SeqCst))
1601                        .await
1602                    {
1603                        eprintln!("worker serve loop ended with error: {error}");
1604                    }
1605                });
1606            });
1607            Self {
1608                stop,
1609                handle: Some(handle),
1610            }
1611        }
1612
1613        fn stop(mut self) {
1614            self.stop.store(true, Ordering::SeqCst);
1615            if let Some(handle) = self.handle.take() {
1616                handle.join().ok();
1617            }
1618        }
1619    }
1620
1621    fn count_completed(history: &[Event]) -> usize {
1622        history
1623            .iter()
1624            .filter(|event| matches!(event, Event::ActivityCompleted { .. }))
1625            .count()
1626    }
1627
1628    fn count_workflow_completed(history: &[Event]) -> usize {
1629        history
1630            .iter()
1631            .filter(|event| matches!(event, Event::WorkflowCompleted { .. }))
1632            .count()
1633    }
1634
1635    async fn wait_for_history<F>(
1636        store: &LibSqlStore,
1637        workflow_id: &aion_core::WorkflowId,
1638        description: &str,
1639        predicate: F,
1640    ) -> Result<Vec<Event>, TestError>
1641    where
1642        F: Fn(&[Event]) -> bool,
1643    {
1644        let deadline = Instant::now() + POLL_DEADLINE;
1645        loop {
1646            let history = store.read_history(workflow_id).await.map_err(test_error)?;
1647            if predicate(&history) {
1648                return Ok(history);
1649            }
1650            if Instant::now() > deadline {
1651                return Err(test_error(format!(
1652                    "timed out waiting for {description}: {history:#?}"
1653                )));
1654            }
1655            tokio::time::sleep(Duration::from_millis(25)).await;
1656        }
1657    }
1658
1659    /// Start the loaded `collect_four` workflow over the REAL HTTP transport.
1660    async fn start_over_http(router: &axum::Router) -> Result<aion_core::WorkflowId, TestError> {
1661        let build_request = || -> Result<Request<body::Body>, TestError> {
1662            Request::builder()
1663                .uri("/workflows/start")
1664                .method("POST")
1665                .header("content-type", "application/json")
1666                .header("x-aion-subject", "ci")
1667                .header("x-aion-namespaces", NAMESPACE)
1668                .body(body::Body::from(
1669                    serde_json::to_vec(&json!({
1670                        "namespace": NAMESPACE,
1671                        "workflow_type": OUTBOX_MODULE,
1672                        "input": { "fixture": "input" },
1673                    }))
1674                    .map_err(test_error)?,
1675                ))
1676                .map_err(test_error)
1677        };
1678        let response = router
1679            .clone()
1680            .oneshot(build_request()?)
1681            .await
1682            .map_err(test_error)?;
1683        let status = response.status();
1684        let bytes = body::to_bytes(response.into_body(), usize::MAX)
1685            .await
1686            .map_err(test_error)?
1687            .to_vec();
1688        if status != StatusCode::OK {
1689            return Err(test_error(format!(
1690                "workflow start over HTTP must succeed, got {status}: {}",
1691                String::from_utf8_lossy(&bytes)
1692            )));
1693        }
1694        let body: serde_json::Value = serde_json::from_slice(&bytes).map_err(test_error)?;
1695        // The HTTP wire contract (`clean_dtos::StartWorkflowResponse`) serializes
1696        // `workflow_id` as a plain UUID string, not a nested `{ uuid }` object.
1697        let workflow_id = body["workflow_id"]
1698            .as_str()
1699            .ok_or_else(|| test_error("start response missing workflow id"))?
1700            .parse::<uuid::Uuid>()
1701            .map_err(test_error)?;
1702        Ok(aion_core::WorkflowId::new(workflow_id))
1703    }
1704
1705    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1706    async fn production_boot_dispatches_executes_and_records_over_liminal() -> Result<(), TestError>
1707    {
1708        let dir = crate::test_support::private_tempdir().map_err(test_error)?;
1709        let db_path = dir.path().join("aion.db");
1710        let package_path = write_package_archive(dir.path())?;
1711        // The production path binds the CONFIGURED listen address, so commit to a
1712        // concrete reserved loopback port the worker can also dial.
1713        let listen_address = reserve_loopback_port()?;
1714
1715        // (A) Build a real ServerState through the production boot path
1716        // (ServerState::build over a libSQL ServerConfig): outbox enabled,
1717        // transport = liminal, the listen address set, collect_four loaded. This
1718        // shares the libSQL leaf as the dispatcher's outbox store (the real boot
1719        // store seam) and installs the production ServerOutboxDeliveryCallback over
1720        // the live engine (gated on outbox.enabled).
1721        let config = server_config(&db_path, package_path, listen_address);
1722        let outbox_config = config.outbox.clone();
1723        let state = ServerState::build(config).await.map_err(test_error)?;
1724
1725        // (B) Drive the EXACT production commissioning function run_server calls:
1726        // it hosts the liminal listener, builds RegistryLiminalDispatch over the
1727        // shared registry + engine callback, and spawns the real OutboxDispatcher.
1728        // Hold the returned listener guard for the test's lifetime, exactly as
1729        // run_server holds it.
1730        let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
1731        // Own-all, generous-default backpressure (single-node e2e): fraction 1 and
1732        // the platform default, so the ceiling never engages — the claim behaves
1733        // exactly as before, proving the production path is byte-identical on default.
1734        let backpressure_settings = BackpressureSettings {
1735            platform_default: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
1736            fraction: crate::worker::OwnedShardFraction::own_all(),
1737        };
1738        let listener_guard = maybe_spawn_outbox_dispatcher(
1739            &state,
1740            &outbox_config,
1741            false,
1742            backpressure_settings,
1743            &shutdown_rx,
1744            "set outbox.liminal_listen_address in the test config",
1745        )
1746        .map_err(test_error)?;
1747
1748        // (C) A REAL remote worker connects IN to the production listener and
1749        // self-registers in-band for the fixture's pool.
1750        let executions = Arc::new(AtomicUsize::new(0));
1751        let worker = WorkerThread::spawn(
1752            listen_address.to_string(),
1753            worker_config()?,
1754            worker_registry(&executions)?,
1755        );
1756
1757        // Wait until the in-band registration landed in the SAME registry the
1758        // dispatch path selects from (every fan-out activity type is eligible).
1759        let registry = state.worker_registry().clone();
1760        let deadline = Instant::now() + Duration::from_secs(5);
1761        loop {
1762            let ready = FAN_ACTIVITY_TYPES.iter().all(|activity_type| {
1763                registry
1764                    .select_worker(NAMESPACE, TASK_QUEUE, activity_type, None)
1765                    .ok()
1766                    .flatten()
1767                    .is_some()
1768            });
1769            if ready {
1770                break;
1771            }
1772            if Instant::now() > deadline {
1773                worker.stop();
1774                return Err(test_error("worker never registered in-band for the pool"));
1775            }
1776            tokio::time::sleep(Duration::from_millis(10)).await;
1777        }
1778
1779        // (D) Start collect_four over the REAL HTTP transport: the engine stages
1780        // four pending outbox rows; the production-wired dispatcher claims and
1781        // pushes each to the worker.
1782        let router = http_router(state.clone()).map_err(test_error)?;
1783        let workflow_id = start_over_http(&router).await?;
1784
1785        // (E) THE PROOF: the worker executed all four activities AND every terminal
1786        // was recorded through the production engine callback (record_fan_out_completion)
1787        // — four ActivityCompleted + one WorkflowCompleted in durable history. This
1788        // is the full round-trip the retired stub never achieved.
1789        let reader = LibSqlStore::open(db_path.clone())
1790            .await
1791            .map_err(test_error)?;
1792        let settled = wait_for_history(&reader, &workflow_id, "fan-out settled", |events| {
1793            count_completed(events) == FAN_OUT && count_workflow_completed(events) == 1
1794        })
1795        .await?;
1796        assert_eq!(
1797            count_completed(&settled),
1798            FAN_OUT,
1799            "every fan-out member must record a terminal through the production callback"
1800        );
1801        assert_eq!(
1802            count_workflow_completed(&settled),
1803            1,
1804            "the workflow must complete exactly once"
1805        );
1806        assert_eq!(
1807            executions.load(Ordering::SeqCst),
1808            FAN_OUT,
1809            "the remote worker must have executed every pushed dispatch exactly once"
1810        );
1811
1812        // Teardown: stop the dispatcher + worker, drop the listener guard (its Drop
1813        // stops the accept worker), shut the engine down so durable appends finish.
1814        shutdown_tx.send(true).ok();
1815        worker.stop();
1816        drop(listener_guard);
1817        state.shutdown().map_err(test_error)?;
1818        Ok(())
1819    }
1820}