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::process::ExitCode;
12
13use tracing::{error, info, warn};
14
15use crate::{
16    ServerConfig, ServerError, ServerState,
17    config::{CliOverrides, NamespaceMode, StoreBackend},
18    observability,
19    shutdown::ShutdownOutcome,
20};
21
22mod doors;
23mod outbox_commission;
24mod transports;
25
26use doors::{bind_doors, serve_until_shutdown};
27use outbox_commission::{
28    BackpressureSettings, maybe_spawn_cluster_supervisor, maybe_spawn_outbox_dispatcher,
29    rebuild_outbox_boot_state,
30};
31use transports::{serve_grpc, serve_http};
32
33/// Run the Aion workflow server until it shuts down, returning the process
34/// exit code.
35///
36/// Initializes the JSON tracing subscriber, loads and validates the merged
37/// configuration (file, environment, then `overrides`), serves the gRPC and
38/// HTTP transports, and drains gracefully after the first termination
39/// signal. Every failure is logged through tracing and mapped to the exit
40/// code contract above; the caller only has to exit with the returned code.
41pub async fn run(overrides: CliOverrides) -> ExitCode {
42    match run_server(overrides).await {
43        Ok(code) => code,
44        Err(error) => {
45            error!(%error, "aion-server failed");
46            if error.is_config() {
47                ExitCode::from(2)
48            } else {
49                ExitCode::FAILURE
50            }
51        }
52    }
53}
54
55/// The where-to-edit half of the missing `outbox.liminal_listen_address`
56/// refusal: a liminal outbox refusal must name the FILE to edit, not just the
57/// key — the operator reading it is exactly the operator who did not write
58/// the config (a scaffolded or setup-script home).
59fn liminal_address_hint(source: &crate::config::ConfigSource) -> String {
60    match source {
61        crate::config::ConfigSource::BuiltInDefaults => {
62            "set AION_OUTBOX_LIMINAL_LISTEN_ADDRESS, or add `liminal_listen_address = \
63             \"127.0.0.1:50061\"` to `[outbox]` in a config file"
64                .to_owned()
65        }
66        source => format!(
67            "add `liminal_listen_address = \"127.0.0.1:50061\"` to `[outbox]` in the {source}"
68        ),
69    }
70}
71
72/// Everything `run_server` must capture from the merged config BEFORE
73/// `ServerState::build` consumes it — the wiring below the build reads these,
74/// not the (moved) config.
75struct PreBuildCaptures {
76    /// The selected backend, surfaced so the boot banner records it.
77    store_backend: StoreBackend,
78    /// Static shard assignment (SS-1): the operator's pinned shard set from
79    /// `[store] owned_shards`. Empty means own ALL shards (single-node
80    /// default). The set is carried into `RuntimeConfig` by `into_parts` and
81    /// applied to the `EngineBuilder` during state construction; surfaced
82    /// here so the boot banner records which shards this node serves. No
83    /// election is performed.
84    owned_shards: Vec<usize>,
85    /// The outbox settings, so the (default-off) outbox dispatcher can be
86    /// wired after state is up. The dispatcher shares the engine's
87    /// already-opened haematite store via `state.outbox_store()`, so no
88    /// store settings are needed.
89    outbox_config: crate::config::OutboxConfig,
90    /// Control-Plane Phase 2 (P2-Q2): the keyed-backpressure inputs — the
91    /// generous platform-default ceiling and this node's owned-shard
92    /// fraction. On a single-node / own-all boot the fraction is 1, so
93    /// per-node ceilings equal the cluster-wide quota and, with the generous
94    /// default and no tenant override, the ceiling never engages
95    /// (byte-identical claim).
96    backpressure_settings: BackpressureSettings,
97    /// The SS-5b failover supervisor knobs. Only a distributed haematite
98    /// boot carries a `[store.cluster]` section; this is `None` for every
99    /// single-node boot, so no supervisor is ever spawned.
100    cluster_config: Option<crate::config::ClusterConfig>,
101    /// The managed-worker supervision policy. Resolution already happened
102    /// during config validation, so this cannot surprise an operator at
103    /// boot; it is re-resolved here because the policy is COMMISSIONED onto
104    /// the supervisor built into state below, and a server without the
105    /// section supervises nothing.
106    supervision_policy: Option<crate::worker::SupervisionPolicy>,
107}
108
109impl PreBuildCaptures {
110    fn from_config(config: &ServerConfig) -> Result<Self, ServerError> {
111        Ok(Self {
112            store_backend: config.store.backend,
113            owned_shards: config.store.owned_shards.clone(),
114            outbox_config: config.outbox.clone(),
115            backpressure_settings: BackpressureSettings::from_config(config),
116            cluster_config: config.store.cluster.clone(),
117            supervision_policy: config.worker_supervision.resolve()?,
118        })
119    }
120}
121
122async fn run_server(cli: CliOverrides) -> Result<ExitCode, ServerError> {
123    observability::tracing::init()?;
124
125    // #180: a boot that discovers no config anywhere first scaffolds
126    // `<AION_HOME>/config.toml` from the embedded template (claim-only-when-
127    // empty), then loads it — config LOAD itself stays pure and read-only.
128    let loaded = crate::config::load_or_scaffold(&cli)?;
129    loaded.resolution.ensure_private_home()?;
130    // Arm the death note as early as the home exists, so every later failure
131    // path — including config validation and state build — runs inside the
132    // ARMED/DISARMED bracket. Two anonymous server deaths on 2026-08-16 are
133    // why this exists; see the module docs for the exact coverage.
134    let death_note = crate::death_note::DeathNote::arm(&loaded.resolution.home)?;
135    let home = loaded.resolution.home.clone();
136    loaded.resolution.log_startup();
137    let liminal_address_hint = liminal_address_hint(&loaded.resolution.source);
138    // The boot-side config heal already logged each inserted field by name;
139    // the banner below carries the count so one line summarizes the boot.
140    let config_healed_field_count = loaded.healed.inserted.len();
141    let config = loaded.config;
142    reject_auth_without_feature(&config)?;
143    // The revision, not just the version. A crate version cannot distinguish
144    // two builds from different commits of the same version, and that is the
145    // distinction an operator needs when deciding whether a restart restores
146    // what was running or substitutes something else (#123). Read here, at
147    // the top of the boot, because the BIRTH CLAIM records it: a `status`
148    // against a booting server must be able to say which build is booting.
149    let build = crate::build_identity::BuildIdentity::current();
150    // 🔴 The home is claimed HERE — before the store is opened, before
151    // anything that can take minutes. A record written at bind left the whole
152    // recovery window invisible: `status` said the home was unclaimed, `stop`
153    // said there was nothing to stop, and the launcher's port probe read the
154    // home as empty and started ANOTHER server, which blocked without a word
155    // on the store's writer lock. Four stacked that way on 2026-08-26.
156    //
157    // The claim needs nothing but this process's own identity and the
158    // addresses the (already-loaded) configuration says this boot will bind,
159    // so it can run at stage zero and refuse a colliding sibling outright.
160    let pid_file_guard = claim_home_at_birth(&home, &config, build.commit)?;
161    let boot_stage = pid_file_guard.stage_reporter();
162    boot_stage.report(
163        crate::control::stage::STAGE_CONFIG,
164        format!(
165            "configuration resolved from {} for home {}",
166            loaded.resolution.source,
167            home.display()
168        ),
169    );
170    let captures = PreBuildCaptures::from_config(&config)?;
171    let state = ServerState::build(config, &boot_stage).await?;
172    reject_tls_until_supported(&state)?;
173
174    let runtime = state.runtime_config();
175    let grpc_address = runtime.listen.grpc;
176    let http_address = runtime.listen.http;
177    log_startup_banner(
178        &state,
179        &captures,
180        &build,
181        &death_note,
182        config_healed_field_count,
183    );
184    // #139 leg C: the assistant ships IN aion. The embedded document is
185    // installed here — after the engine has reloaded every persisted package,
186    // so the install can see what is already resident, and before the
187    // transports accept traffic, so the first caller finds it. It claims only a
188    // catalog holding no version of the assistant type; anything else is the
189    // operator's cut to make, and the outcome says so in the log either way.
190    let assistant_install =
191        crate::assistant::install_embedded_assistant_for_server(&state, &liminal_address_hint)
192            .await;
193    // #189 slice one: the built-in update check ships the same way, under the
194    // same only-the-empty-case install rule. Installing makes it STARTABLE
195    // and nothing else — no check runs without an explicit operator act.
196    crate::update_check::install_embedded_update_check_for_server(&state).await;
197    let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
198    // LSUB-4-1: a distributed haematite boot carries a `[store.cluster]` section.
199    // The single outbox dispatcher task is spawned in BOTH modes; the difference
200    // is only how ownership is enforced. Single-node (`None`) owns all shards by
201    // construction (`owned_shard_scope() == None`), so its claim sweeps see every
202    // row. Clustered (`Some`) relies on `claim_outbox_rows`' `owned_shard_scope()`
203    // filter — already seeded by `set_owned_shards` during `ServerState::build`,
204    // which runs before this point — so each node only ever claims rows on the
205    // shards it owns. Compute the flag here where the cluster section is in
206    // scope; pass it to the gate so the boot banner records the mode.
207    let outbox_clustered = captures.cluster_config.is_some();
208    // Dormant by default: only when `outbox.enabled` is set does the
209    // non-replayed outbox dispatcher task start. With the flag off (the
210    // default) nothing here runs and server behaviour is unchanged.
211    // Hold the liminal worker listener (if any) for the server's lifetime: it is
212    // dropped at the end of `run_server`, after the serve `select!` completes, so
213    // its accept worker stops cleanly on shutdown via the listener's own `Drop`.
214    // #204/#253: rebuild the pause dispatch-hold and settle terminal
215    // workflows' stranded outbox rows BEFORE the dispatcher's first claim.
216    rebuild_outbox_boot_state(&state, &captures.outbox_config).await;
217    let _outbox_worker_listener = maybe_spawn_outbox_dispatcher(
218        &state,
219        &captures.outbox_config,
220        outbox_clustered,
221        captures.backpressure_settings,
222        &shutdown_rx,
223        &liminal_address_hint,
224    )?;
225    // SS-5b: a distributed boot whose peers declare owned shards runs the cluster
226    // supervisor — automatic failover detection. A single-node boot spawns
227    // nothing here (the method returns `false`), so default behaviour is
228    // unchanged.
229    maybe_spawn_cluster_supervisor(&state, captures.cluster_config.as_ref(), &shutdown_rx)?;
230    // #176: the worker heartbeat expiry sweeper is ALWAYS commissioned —
231    // dead-worker detection is a liveness correctness property, not an opt-in
232    // feature. It is the production caller of `fail_expired_workers`: a worker
233    // whose stream stays open while its process wedges (stops heartbeating
234    // without disconnecting) is expired, deregistered with the provable Timeout
235    // reason, and its in-flight tasks surface as TRANSPORT losses, re-dispatched
236    // attempt-neutrally rather than charged to the action's retry budget.
237    // Cadence derives from `worker.heartbeat_window` (quarter-window, clamped to
238    // [1s, window]; the default 30s window sweeps every 7.5s) — deliberately no
239    // separate config knob. It drains on the same shutdown watch as the
240    // transports; dropping the JoinHandle only detaches the task.
241    drop(state.spawn_heartbeat_sweeper(shutdown_rx.clone()));
242    commission_worker_supervision(&state, captures.supervision_policy).await;
243    // A record freezes the address it was minted with and the supervisor
244    // replays that argv verbatim, so an operator who moves
245    // `[outbox] liminal_listen_address` would otherwise restart into a fleet of
246    // built-in agent workers all dialling a port nothing binds. Correct the
247    // connection — and only the connection — before anything else is decided.
248    drop(crate::worker::auto_provision::refresh_dial_addresses(&state).await);
249    // The assistant is INSTALLED from the binary, never deployed, so nothing on
250    // the deploy path ever provisions its queue. Mint the record here, and
251    // deliberately AFTER the commission above: the record does not exist when
252    // that line's reconcile runs, so this must converge it itself, which it can
253    // only do once a policy is installed. The position also puts the
254    // dark-outbox decision after the outbox was commissioned, so the refusal
255    // is decided against this boot's real listener rather than a guess.
256    drop(
257        crate::worker::auto_provision::provision_embedded_assistant(&state, &assistant_install)
258            .await,
259    );
260    // Bind both listeners, then FILL the record this incarnation already
261    // holds: the bound addresses, the resolved drain window, and the move out
262    // of BOOTING into SERVING. The claim itself happened at birth; there is
263    // no second claim, and nothing here can take another server's record.
264    boot_stage.report(
265        crate::control::stage::STAGE_BINDING,
266        format!("binding http {http_address} and grpc {grpc_address}"),
267    );
268    let doors = bind_doors(
269        &pid_file_guard,
270        grpc_address,
271        http_address,
272        state.runtime_config().drain_timeout,
273    )
274    .await?;
275    let identity_pid = doors.identity_pid;
276    // Instant doors: the startup catch-up legs (owed timer fires, schedule
277    // catch-up) run as a background task CONCURRENT with the transports —
278    // the backlog has no upper bound, and a boot that blocks on it keeps the
279    // doors shut for the whole sweep (the 2026-08-24 estate outage shape:
280    // 37+ minutes of healthy catch-up with every listener refusing).
281    // Workflow-residency recovery already ran inside `ServerState::build`,
282    // so every surface the transports serve answers correctly while the
283    // catch-up drains behind them.
284    drop(state.spawn_startup_catchup(shutdown_rx.clone())?);
285    let mut grpc = tokio::spawn(serve_grpc(
286        state.clone(),
287        doors.grpc_listener,
288        doors.bound_grpc,
289        shutdown_rx.clone(),
290    ));
291    let mut http = tokio::spawn(serve_http(
292        state.clone(),
293        doors.http_listener,
294        doors.bound_http,
295        shutdown_rx,
296    ));
297
298    // From here the graceful drain is watching for a termination signal, so
299    // the death note's watcher goes back to merely OBSERVING one. Announced
300    // as late as possible and no later: everything before this line is boot,
301    // and a termination signal during the boot must ABANDON it rather than be
302    // caught and answered by nobody (the 2026-08-26 finding — see
303    // `DeathNote::drain_owns_termination`).
304    death_note.drain_owns_termination();
305    let report =
306        serve_until_shutdown(&state, &pid_file_guard, &shutdown_tx, &mut grpc, &mut http).await?;
307
308    let outcome = report.outcome;
309    let exit_code = outcome.exit_code();
310    // The rich outcome crosses the process boundary through the death note
311    // (one file, one writer); the exit code keeps the #207 contract.
312    death_note.record_outcome(&crate::control::outcome::OutcomeRecord::from_report(
313        identity_pid,
314        &report,
315    ));
316    death_note.disarm(&format!(
317        "clean run-loop exit: shutdown outcome {outcome:?}"
318    ));
319    drop(pid_file_guard);
320    Ok(exit_code)
321}
322
323/// The one line an operator greps for when they want to know what this server
324/// actually is: build identity, addresses, backend, which surfaces are on,
325/// which shards it owns, and where its death note lives.
326fn log_startup_banner(
327    state: &ServerState,
328    captures: &PreBuildCaptures,
329    build: &crate::build_identity::BuildIdentity,
330    death_note: &crate::death_note::DeathNote,
331    config_healed_field_count: usize,
332) {
333    let runtime = state.runtime_config();
334    let grpc_address = runtime.listen.grpc;
335    let http_address = runtime.listen.http;
336    let workflow_packages: Vec<String> = runtime
337        .workflow_packages
338        .iter()
339        .map(|path| path.display().to_string())
340        .collect();
341    // #139: the server-resolved workspace root (the aion home's `clones/`
342    // directory) that declared bodies expand `{workspace_root}` with. Reported
343    // here so composition points (setup.sh today, the workspace verb later)
344    // READ the value from the server that will use it instead of re-deriving
345    // it. An unresolvable root is reported as exactly that — never fabricated;
346    // a placeholder-bearing dispatch will refuse terminally with this reason.
347    // The rendering itself is `WorkspaceRoot::banner_value`, pinned by its own
348    // two-case test, so the banner and the tests cannot drift apart.
349    let workspace_root = state.workspace_root().banner_value();
350    info!(
351        version = env!("CARGO_PKG_VERSION"),
352        build = %build.line(),
353        commit = build.commit,
354        grpc_address = %grpc_address,
355        http_address = %http_address,
356        default_namespace = %runtime.default_namespace,
357        namespace_mode = namespace_mode_label(&runtime.namespace.mode),
358        store_backend = store_backend_label(captures.store_backend),
359        auth_enabled = runtime.auth.enabled,
360        deploy_enabled = runtime.deploy.enabled,
361        metrics_enabled = runtime.metrics.enabled,
362        workspace_root = %workspace_root,
363        death_note = %death_note.path().display(),
364        workflow_package_count = workflow_packages.len(),
365        workflow_packages = ?workflow_packages,
366        owned_shards = ?captures.owned_shards,
367        owns_all_shards = captures.owned_shards.is_empty(),
368        config_healed_field_count,
369        "aion-server startup banner"
370    );
371}
372
373/// Claim this home before anything slow happens.
374///
375/// Everything the claim needs is available at stage zero: this process's own
376/// identity, the build's commit, and the addresses the already-loaded
377/// configuration says this boot will bind. Nothing here touches the store,
378/// which is the entire point — the record has to exist BEFORE the minutes.
379fn claim_home_at_birth(
380    home: &std::path::Path,
381    config: &ServerConfig,
382    commit: &str,
383) -> Result<crate::control::PidFileGuard, ServerError> {
384    let intended = crate::control::IntendedAddresses {
385        http: config.server.listen_address,
386        grpc: config.server.grpc_address,
387    };
388    crate::control::claim_at_birth(home, &birth_record(commit, intended)?, intended)
389}
390
391/// This incarnation's record as it exists at BIRTH.
392///
393/// Identity, build, and the addresses the already-loaded configuration says
394/// this boot WILL bind. The BOUND addresses stay `None` — nothing is bound,
395/// and that absence is a FACT rather than a placeholder: it is how a reader
396/// knows the doors are not open yet, and how `aion server status` says
397/// "still booting" instead of "unreachable". The INTENDED pair is recorded
398/// beside them because it is equally a fact at birth, and it is what lets a
399/// concurrent boot on this home decide collision against this server's
400/// configuration instead of presuming it. No drain window either — the
401/// runtime configuration has not been resolved into one yet.
402///
403/// [`Booting`]: crate::control::IncarnationState::Booting
404fn birth_record(
405    commit: &str,
406    intended: crate::control::IntendedAddresses,
407) -> Result<crate::control::PidRecord, ServerError> {
408    let identity = crate::control::incarnation::self_identity()?;
409    Ok(crate::control::PidRecord {
410        pid: identity.pid,
411        started_at_unix_secs: identity.started_at_unix_secs,
412        binary_sha256: identity.binary_sha256,
413        version: env!("CARGO_PKG_VERSION").to_owned(),
414        commit: commit.to_owned(),
415        state: crate::control::IncarnationState::Booting,
416        http_address: None,
417        grpc_address: None,
418        intended_http_address: Some(intended.http),
419        intended_grpc_address: Some(intended.grpc),
420        stage: None,
421        stage_detail: None,
422        stage_seq: 0,
423        stage_updated_at_unix_secs: 0,
424        drain_timeout_seconds: 0,
425    })
426}
427
428/// Install the operator's supervision policy and converge the fleet.
429///
430/// Uncommissioned is a first-class but never SILENT state: a server with no
431/// `[worker_supervision]` section supervises nothing, and every deployment that
432/// wanted to be running is named in the warning, so the gap between "the
433/// operator deployed a worker" and "nothing is running it" is never quiet.
434async fn commission_worker_supervision(
435    state: &ServerState,
436    policy: Option<crate::worker::SupervisionPolicy>,
437) {
438    let supervisor = state.worker_supervisor();
439    let Some(policy) = policy else {
440        match supervisor.report().await {
441            Ok(report) => {
442                let wanted: Vec<&str> = report
443                    .workers
444                    .iter()
445                    .filter(|worker| worker.desired == aion_store::DesiredState::Running)
446                    .map(|worker| worker.name.as_str())
447                    .collect();
448                if wanted.is_empty() {
449                    info!("managed-worker supervision is not configured; no deployment wants it");
450                } else {
451                    warn!(
452                        deployments = wanted.join(", "),
453                        remedy = crate::worker::supervisor::UNCOMMISSIONED_REMEDY,
454                        "worker deployments want to be running but supervision is not configured"
455                    );
456                }
457            }
458            Err(error) => error!(
459                %error,
460                "managed-worker supervision is not configured and the deployment records \
461                 could not be read to say what that costs"
462            ),
463        }
464        return;
465    };
466    if !supervisor.commission(policy, crate::worker::ManagedExecutable::CurrentServer) {
467        error!("managed-worker supervision was already commissioned before boot completed");
468        return;
469    }
470    match supervisor.reconcile().await {
471        Ok(0) => info!("managed-worker supervision commissioned; no deployment wants to run"),
472        Ok(supervised) => info!(supervised, "managed-worker supervision commissioned"),
473        Err(error) => error!(%error, "managed-worker fleet could not be converged at boot"),
474    }
475}
476
477fn reject_auth_without_feature(config: &ServerConfig) -> Result<(), ServerError> {
478    if cfg!(not(feature = "auth")) && config.auth.enabled {
479        return Err(ServerError::Config {
480            message: "auth.enabled=true but binary compiled without auth feature".to_owned(),
481        });
482    }
483    Ok(())
484}
485
486fn reject_tls_until_supported(state: &ServerState) -> Result<(), ServerError> {
487    if state.runtime_config().tls.is_some() {
488        return Err(ServerError::Config {
489            message: "configured TLS material cannot be served until transport TLS is wired"
490                .to_owned(),
491        });
492    }
493    Ok(())
494}
495
496fn store_backend_label(backend: StoreBackend) -> &'static str {
497    match backend {
498        StoreBackend::Memory => "memory",
499        StoreBackend::Haematite => "haematite",
500    }
501}
502
503fn namespace_mode_label(mode: &NamespaceMode) -> &'static str {
504    match mode {
505        NamespaceMode::SharedEngine => "SharedEngine",
506        NamespaceMode::SingleTenant { .. } => "SingleTenant",
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    #![allow(clippy::expect_used)]
513
514    use super::outbox_commission::{
515        BackpressureSettings, maybe_spawn_outbox_dispatcher, resolve_outbox_reconciler_config,
516    };
517    use crate::ServerState;
518    use crate::config::RuntimeConfig;
519    use crate::config::{OutboxConfig, OutboxTransport};
520    use aion_store::InMemoryStore;
521    use std::net::SocketAddr;
522    use std::time::Duration;
523
524    /// Own-all, generous-default backpressure settings for the gate tests (the
525    /// single-node default: fraction 1, so the ceiling never engages).
526    fn test_backpressure_settings() -> BackpressureSettings {
527        BackpressureSettings {
528            platform_default: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
529            fraction: crate::worker::OwnedShardFraction::own_all(),
530        }
531    }
532
533    /// A minimal `RuntimeConfig` for building an in-memory `ServerState` in unit
534    /// tests (mirrors `state.rs`'s test `runtime_config`).
535    fn runtime_config() -> RuntimeConfig {
536        use crate::config::{
537            AuthConfig, AuthoringConfig, DeployConfig, DevConfig, ListenConfig, MetricsConfig,
538            NamespaceConfig, NamespaceMode, OpsConsoleAssetSource, OpsConsoleConfig,
539            WebSocketConfig, WorkerConfig,
540        };
541        RuntimeConfig {
542            listen: ListenConfig {
543                grpc: SocketAddr::from(([127, 0, 0, 1], 50051)),
544                http: SocketAddr::from(([127, 0, 0, 1], 8080)),
545            },
546            tls: None,
547            auth: AuthConfig {
548                enabled: false,
549                jwks_url: None,
550                jwks_refresh_seconds: 300,
551            },
552            ops_console: OpsConsoleConfig {
553                source: OpsConsoleAssetSource::Embedded,
554            },
555            namespace: NamespaceConfig {
556                mode: NamespaceMode::SharedEngine,
557            },
558            worker: WorkerConfig {
559                heartbeat_window: Duration::from_secs(30),
560                ..WorkerConfig::default()
561            },
562            websocket: WebSocketConfig {
563                outbound_buffer_bound: 32,
564                event_broadcast_capacity: Some(64),
565                cluster_broadcast_capacity: Some(64),
566            },
567            workflow_packages: Vec::new(),
568            deploy: DeployConfig::default(),
569            authoring: AuthoringConfig::default(),
570            dev: DevConfig::default(),
571            outbox: OutboxConfig::default(),
572            observability: crate::config::ObservabilityConfig::with_flush_policy(64, 0),
573            mcp: crate::config::ResolvedMcpConfig::default(),
574            scheduler_threads: 1,
575            jit_threshold: None,
576            query_timeout: Some(Duration::from_secs(10)),
577            workloop_sweep_interval: Some(Duration::from_millis(50)),
578            default_namespace: "default".to_owned(),
579            auto_create: crate::config::AutoCreate::Open,
580            max_in_flight_activities: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
581            drain_timeout: Duration::from_secs(30),
582            metrics: MetricsConfig { enabled: true },
583            owned_shards: Vec::new(),
584            cors_allowed_origins: Vec::new(),
585        }
586    }
587
588    /// An `OutboxConfig` with `enabled = true` and every required knob present, so
589    /// the only remaining gate is the store-backend / outbox-table availability.
590    fn enabled_outbox_config() -> OutboxConfig {
591        OutboxConfig {
592            enabled: true,
593            poll_interval_ms: Some(250),
594            batch_size: Some(64),
595            max_attempts: Some(5),
596            backoff_base_ms: Some(100),
597            backoff_multiplier: Some(2),
598            backoff_max_ms: Some(30_000),
599            reconcile_interval_ms: None,
600            reconcile_stale_after_ms: None,
601            transport: OutboxTransport::Grpc,
602            liminal_listen_address: None,
603        }
604    }
605
606    /// LSUB-4-2 / LSUB-4-6 (Memory-backend guard): commissioning the outbox
607    /// dispatcher against the in-memory backend (which has no outbox table, so
608    /// `outbox_store()` is `None`) is a configuration error, and the message names
609    /// haematite as the required durable backend.
610    #[tokio::test]
611    async fn outbox_enabled_on_memory_backend_is_a_config_error() {
612        let state = ServerState::build_with_store(InMemoryStore::default(), runtime_config())
613            .await
614            .expect("build in-memory state");
615        let (_tx, rx) = tokio::sync::watch::channel(false);
616        let error = maybe_spawn_outbox_dispatcher(
617            &state,
618            &enabled_outbox_config(),
619            false,
620            test_backpressure_settings(),
621            &rx,
622            "set outbox.liminal_listen_address in the test config",
623        )
624        .expect_err("outbox.enabled on the memory backend must be a config error");
625        assert!(
626            error.is_config(),
627            "memory-backend outbox error must be Config"
628        );
629        let message = error.to_string();
630        assert!(
631            message.contains("store.backend=haematite"),
632            "message must name the durable backend, got: {message}"
633        );
634    }
635
636    /// LSUB-4-1 (Fork-B fast path): with the outbox disabled (the default), the
637    /// gate is a no-op even on a memory backend — nothing is spawned and no error
638    /// is produced, so a default single-node boot is unchanged.
639    #[tokio::test]
640    async fn disabled_outbox_is_a_noop_on_any_backend() {
641        let state = ServerState::build_with_store(InMemoryStore::default(), runtime_config())
642            .await
643            .expect("build in-memory state");
644        let (_tx, rx) = tokio::sync::watch::channel(false);
645        maybe_spawn_outbox_dispatcher(
646            &state,
647            &OutboxConfig::default(),
648            false,
649            test_backpressure_settings(),
650            &rx,
651            "set outbox.liminal_listen_address in the test config",
652        )
653        .expect("disabled outbox gate must be an infallible no-op");
654    }
655
656    /// LSUB-4-4: the reconciler config resolves to `None` unless BOTH knobs are
657    /// set — the condition under which the clustered-boot WARN fires.
658    #[test]
659    fn reconciler_config_absent_unless_both_knobs_set() {
660        let mut config = enabled_outbox_config();
661        // Neither knob: absent.
662        assert!(
663            resolve_outbox_reconciler_config(&config)
664                .expect("resolve")
665                .is_none()
666        );
667        // Only interval: still absent (the silent-backstop-absent default).
668        config.reconcile_interval_ms = Some(1_000);
669        assert!(
670            resolve_outbox_reconciler_config(&config)
671                .expect("resolve")
672                .is_none()
673        );
674        // Both set: present.
675        config.reconcile_stale_after_ms = Some(60_000);
676        assert!(
677            resolve_outbox_reconciler_config(&config)
678                .expect("resolve")
679                .is_some()
680        );
681    }
682
683    /// LSUB-PROD (13-6): the liminal transport requires `liminal_listen_address`.
684    /// Commissioning the dispatcher with `transport = liminal` but no listen
685    /// address is a configuration error naming the missing knob, rather than a
686    /// panic or a silent fall-through to gRPC. Built over haematite (so
687    /// the outbox-store gate passes and the missing-address check is actually
688    /// reached). (Feature-gated: the liminal arm of `build_liminal_row_dispatch`
689    /// only exists with `liminal-transport` on; in a feature-off build the same
690    /// selection is the missing-feature error instead, covered by the type system
691    /// rather than this test.)
692    #[cfg(feature = "liminal-transport")]
693    #[tokio::test]
694    async fn liminal_transport_requires_listen_address() {
695        use crate::config::{
696            RuntimeSection, ServerConfig, StoreBackend, StoreConfig, WebSocketConfig,
697        };
698
699        let data_dir = std::env::temp_dir().join(format!(
700            "aion-lsub-prod-listen-guard-{}-{}",
701            std::process::id(),
702            std::time::SystemTime::now()
703                .duration_since(std::time::UNIX_EPOCH)
704                .map(|elapsed| elapsed.as_nanos())
705                .unwrap_or_default()
706        ));
707        let mut outbox = enabled_outbox_config();
708        outbox.transport = OutboxTransport::Liminal;
709        outbox.liminal_listen_address = None;
710        let config = ServerConfig {
711            store: StoreConfig {
712                backend: StoreBackend::Haematite,
713                data_dir: Some(data_dir.to_string_lossy().into_owned()),
714                // Required, no default: the haematite boot path refuses a config
715                // that does not rule on the node cache's byte ceiling.
716                node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
717                ..StoreConfig::default()
718            },
719            runtime: RuntimeSection {
720                scheduler_threads: 1,
721                jit_threshold: None,
722                workloop_sweep_interval_ms: Some(50),
723                query_timeout_ms: Some(10_000),
724            },
725            websocket: WebSocketConfig {
726                outbound_buffer_bound: 32,
727                event_broadcast_capacity: Some(64),
728                cluster_broadcast_capacity: Some(64),
729            },
730            outbox: outbox.clone(),
731            // Required, no default: the transcript drain's flush policy.
732            observability: crate::config::ObservabilityConfig::with_flush_policy(64, 0),
733            ..ServerConfig::default()
734        };
735        let state = ServerState::build(config, &crate::control::StageReporter::detached())
736            .await
737            .expect("build haematite state");
738        let (_tx, rx) = tokio::sync::watch::channel(false);
739
740        let error = maybe_spawn_outbox_dispatcher(
741            &state,
742            &outbox,
743            false,
744            test_backpressure_settings(),
745            &rx,
746            "add `liminal_listen_address = \"127.0.0.1:50061\"` to `[outbox]` in the test config",
747        )
748        .expect_err("liminal transport without a listen address must be a config error");
749        assert!(
750            error.is_config(),
751            "missing-listen-address error must be Config"
752        );
753        assert!(
754            error.to_string().contains("liminal_listen_address"),
755            "error must name the missing knob, got: {error}"
756        );
757        // #180 review MAJ-4: the refusal must carry the caller's threaded
758        // where-to-edit hint, so the production message names the resolved
759        // config FILE, not just the key.
760        assert!(
761            error.to_string().contains("in the test config"),
762            "error must carry the threaded config-location hint, got: {error}"
763        );
764    }
765}
766
767/// LSUB-PROD (13-6): production-boot cross-node round-trip over the REAL wiring.
768///
769/// This is the proof that the production boot now does the full round-trip the
770/// retired stub could not. It drives the EXACT production commissioning function
771/// `run_server` calls — [`maybe_spawn_outbox_dispatcher`] — over a real
772/// [`ServerState`] built with `outbox.enabled`, `transport = liminal`, and a
773/// `liminal_listen_address`. That function lifts the full push wiring
774/// (`build_liminal_row_dispatch`): it hosts the liminal worker listener, builds
775/// [`RegistryLiminalDispatch`](crate::worker::RegistryLiminalDispatch) over the
776/// SAME registry the gRPC path uses and the SAME
777/// [`ServerOutboxDeliveryCallback`](crate::worker::ServerOutboxDeliveryCallback)
778/// (over the live engine), and spawns the real [`OutboxDispatcher`].
779///
780/// A REAL remote [`LiminalActivityWorker`](aion_worker::LiminalActivityWorker)
781/// connects IN to the listener and self-registers in-band. A `collect_four`
782/// fan-out is started over the REAL HTTP transport, which stages four pending
783/// outbox rows; the production-wired dispatcher claims and pushes each to the
784/// worker, the worker executes it, and its completion re-enters aion through the
785/// production engine callback — `record_fan_out_completion` — driving the
786/// workflow to a recorded terminal. The proof asserts BOTH: the worker observably
787/// executed the activities, AND the terminals were recorded in history (four
788/// `ActivityCompleted` + one `WorkflowCompleted`), which the stub's
789/// publish-and-mark-done path never achieved.
790#[cfg(all(test, feature = "liminal-transport"))]
791mod lsub_prod_xnode_e2e {
792    #![allow(clippy::expect_used)]
793
794    use std::net::SocketAddr;
795    use std::path::PathBuf;
796    use std::sync::Arc;
797    use std::sync::atomic::{AtomicUsize, Ordering};
798    use std::time::{Duration, Instant};
799
800    use aion_core::Event;
801    use aion_package::{
802        ActionContract, BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, Manifest,
803        ManifestVersion, PackageBuilder, PackageContract, WorkerContract,
804    };
805    use aion_worker::{ActivityRegistry, LiminalActivityWorker, WorkerConfig};
806    use axum::body;
807    use axum::http::{Request, StatusCode};
808    use serde_json::json;
809    use tower::ServiceExt;
810
811    use super::{BackpressureSettings, maybe_spawn_outbox_dispatcher};
812    use crate::ServerState;
813    use crate::api::http::http_router;
814    use crate::config::{
815        OutboxConfig, OutboxTransport, RuntimeSection, ServerConfig, StoreBackend, StoreConfig,
816        WebSocketConfig,
817    };
818
819    type TestError = Box<dyn std::error::Error + Send + Sync>;
820
821    /// The `collect_four` fixture passes each member the JSON string `"in"` as
822    /// activity input, so the worker handler decodes a [`String`], not a struct.
823    type FanInput = String;
824
825    const NAMESPACE: &str = "default";
826    const TASK_QUEUE: &str = "default";
827    const OUTBOX_MODULE: &str = "aion_outbox_fixture";
828    const OUTBOX_BEAM: &[u8] = include_bytes!("../tests/fixtures/aion_outbox_fixture.beam");
829    const OUTBOX_SOURCE: &[u8] = include_bytes!("../tests/fixtures/aion_outbox_fixture.erl");
830    const FAN_OUT: usize = 4;
831    const FAN_ACTIVITY_TYPES: [&str; FAN_OUT] = ["fan:0", "fan:1", "fan:2", "fan:3"];
832    const POLL_DEADLINE: Duration = Duration::from_secs(20);
833    /// The one fan-out member the reconnect pin holds. Any of the four would do —
834    /// they are dispatched independently and served by identical handlers.
835    const HELD_ACTIVITY_TYPE: &str = FAN_ACTIVITY_TYPES[0];
836
837    fn test_error(message: impl std::fmt::Display) -> TestError {
838        message.to_string().into()
839    }
840
841    /// Reserve a loopback port and return it: the liminal listener binds this exact
842    /// address (the production path binds the configured `liminal_listen_address`,
843    /// so the test must commit to a concrete port the worker can also dial).
844    fn reserve_loopback_port() -> Result<SocketAddr, TestError> {
845        let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(test_error)?;
846        let address = listener.local_addr().map_err(test_error)?;
847        drop(listener);
848        Ok(address)
849    }
850
851    /// The fixture's queue-scoped `.v4` contract: the four `fan:N` activities
852    /// `collect_four` schedules, declared on the queue its worker actually polls.
853    ///
854    /// Why the archive cannot just carry the manifest-derived record: by design
855    /// `PackageContract::from_manifest` "never invents a queue", so a manifest's
856    /// bare activity names land in `unscoped_activities` — and this server boots
857    /// queue-routed, where an unscoped catalog is a terminal
858    /// `NO_QUEUE_DECLARATION` at start admission
859    /// (`aion::lifecycle::start_admission`). That refusal is EARNED: an unserved
860    /// queue would otherwise wait silently forever. So the derived record is
861    /// amended rather than bypassed — the same four names move out of
862    /// `unscoped_activities` and onto the queue that serves them — and the
863    /// package still loads through the production boot path with the `.v4`
864    /// identity `PackageBuilder` stamps over this exact contract.
865    ///
866    /// The action schemas come from the SAME generator the worker's typed
867    /// registry uses, for the SAME Rust types: `collect_four` passes each member
868    /// the JSON string `"in"` and the handler returns a [`String`]. Deriving both
869    /// sides from `activity_descriptor::<FanInput, String>` means the package's
870    /// declaration and the worker's advertisement cannot drift apart, so
871    /// registration admission (`WORKER_CONTRACT_MISMATCH`) compares two schemas
872    /// with one source.
873    fn fixture_contract(manifest: &Manifest) -> Result<PackageContract, TestError> {
874        let mut actions = Vec::with_capacity(FAN_ACTIVITY_TYPES.len());
875        for activity_type in FAN_ACTIVITY_TYPES {
876            let descriptor = aion_worker::activity_descriptor::<FanInput, String>(activity_type)
877                .map_err(test_error)?;
878            actions.push(ActionContract {
879                name: descriptor.name,
880                input_schema: descriptor.input_schema,
881                output_schema: descriptor.output_schema,
882                node: None,
883                timeout: None,
884                retry: None,
885                advisory: false,
886                // A typed `String -> String` handler serves these, not an agent
887                // harness — the fan fixture's shape merely coincides with an
888                // agent seam's, and marking it would route it somewhere no
889                // handler is.
890                agent: false,
891                // A connected worker serves this fixture's queue, so the
892                // declaration carries no body of its own.
893                body: None,
894            });
895        }
896        let mut contract = PackageContract::from_manifest(manifest);
897        contract.workers = vec![WorkerContract {
898            task_queue: TASK_QUEUE.to_owned(),
899            actions,
900        }];
901        contract.unscoped_activities.clear();
902        Ok(contract)
903    }
904
905    /// Build the `collect_four` package on disk so the production state-build path
906    /// loads it exactly as it loads operator-supplied `workflow_packages`.
907    fn write_package_archive(dir: &std::path::Path) -> Result<PathBuf, TestError> {
908        let beams =
909            BeamSet::new(vec![BeamModule::new(OUTBOX_MODULE, OUTBOX_BEAM)]).map_err(test_error)?;
910        let manifest = Manifest {
911            entry_module: OUTBOX_MODULE.to_owned(),
912            entry_function: "collect_four".to_owned(),
913            input_schema: json!({ "type": "object" }),
914            output_schema: json!({}),
915            timeout: Some(Duration::from_secs(30)),
916            // The four ordinals `collect_four` actually fans out. This manifest
917            // used to name one invented activity, `fixture_activity`, that the
918            // fixture never schedules and no worker ever served.
919            activities: FAN_ACTIVITY_TYPES
920                .iter()
921                .map(|activity_type| DeclaredActivity {
922                    activity_type: (*activity_type).to_owned(),
923                })
924                .collect(),
925            version: ManifestVersion::new("stamped-by-builder"),
926            format_version: CURRENT_FORMAT_VERSION,
927            additional_workflows: Vec::new(),
928        };
929        let contract = fixture_contract(&manifest)?;
930        let archive =
931            PackageBuilder::with_source(manifest, beams, [(OUTBOX_MODULE, OUTBOX_SOURCE.to_vec())])
932                .with_contract(contract)
933                .write_to_bytes()
934                .map_err(test_error)?;
935        let path = dir.join("collect_four.aion");
936        std::fs::write(&path, archive).map_err(test_error)?;
937        Ok(path)
938    }
939
940    /// A production-shaped `ServerConfig`: the haematite backend (so the boot store
941    /// path shares the leaf as the dispatcher's outbox store, exactly as
942    /// `ServerState::build` does in production), `outbox.enabled`,
943    /// `transport = liminal`, the reserved `liminal_listen_address`, and the
944    /// `collect_four` package. Built through `ServerState::build` (not
945    /// `build_with_store`), so this is the real boot store seam, not a test stand-in.
946    fn server_config(
947        data_dir: &std::path::Path,
948        package_path: PathBuf,
949        listen_address: SocketAddr,
950    ) -> ServerConfig {
951        ServerConfig {
952            store: StoreConfig {
953                backend: StoreBackend::Haematite,
954                data_dir: Some(data_dir.to_string_lossy().into_owned()),
955                // Required, no default: the haematite boot path refuses a config
956                // that does not rule on the node cache's byte ceiling.
957                node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
958                ..StoreConfig::default()
959            },
960            runtime: RuntimeSection {
961                scheduler_threads: 1,
962                jit_threshold: None,
963                workloop_sweep_interval_ms: Some(50),
964                query_timeout_ms: Some(10_000),
965            },
966            websocket: WebSocketConfig {
967                outbound_buffer_bound: 32,
968                event_broadcast_capacity: Some(64),
969                cluster_broadcast_capacity: Some(64),
970            },
971            workflow_packages: vec![package_path],
972            outbox: OutboxConfig {
973                enabled: true,
974                poll_interval_ms: Some(20),
975                batch_size: Some(16),
976                max_attempts: Some(5),
977                backoff_base_ms: Some(50),
978                backoff_multiplier: Some(2),
979                backoff_max_ms: Some(1_000),
980                reconcile_interval_ms: None,
981                reconcile_stale_after_ms: None,
982                transport: OutboxTransport::Liminal,
983                liminal_listen_address: Some(listen_address.to_string()),
984            },
985            // Required, no default: the transcript drain's flush policy.
986            observability: crate::config::ObservabilityConfig::with_flush_policy(64, 0),
987            ..ServerConfig::default()
988        }
989    }
990
991    /// The remote worker self-describes for the fixture's pool `(default, default)`
992    /// and registers a handler for every `fan:N` activity type, counting executions
993    /// so the test proves it genuinely ran the pushed dispatches.
994    fn worker_config() -> Result<WorkerConfig, TestError> {
995        WorkerConfig::builder()
996            .endpoint("unused-direct-address")
997            .namespace(NAMESPACE)
998            .task_queue(TASK_QUEUE)
999            .identity("lsub-prod-worker")
1000            .max_concurrency(4)
1001            .reconnect_initial_backoff(Duration::from_millis(5))
1002            .reconnect_max_backoff(Duration::from_millis(20))
1003            .reconnect_max_attempts(3)
1004            .build()
1005            .map_err(test_error)
1006    }
1007
1008    fn worker_registry(executions: &Arc<AtomicUsize>) -> Result<Arc<ActivityRegistry>, TestError> {
1009        let mut registry = ActivityRegistry::new();
1010        for activity_type in FAN_ACTIVITY_TYPES {
1011            let executions = Arc::clone(executions);
1012            // `register_activity_with_contract`, not `register_activity`: the
1013            // bare form registers a handler with NO descriptor, so the worker
1014            // advertises four names and zero typed contracts, and admission —
1015            // which compares CONTRACTS — refuses the registration outright
1016            // (`WORKER_CONTRACT_MISMATCH`). Deriving the advertisement from
1017            // `<FanInput, String>` is what makes it the same source the
1018            // package's `fixture_contract` declares from, so the two sides
1019            // cannot drift.
1020            registry = registry
1021                .register_activity_with_contract(
1022                    activity_type,
1023                    move |_input: FanInput, _context| {
1024                        let executions = Arc::clone(&executions);
1025                        Box::pin(async move {
1026                            executions.fetch_add(1, Ordering::SeqCst);
1027                            Ok(activity_type.to_owned())
1028                        })
1029                    },
1030                )
1031                .map_err(test_error)?;
1032        }
1033        Ok(Arc::new(registry))
1034    }
1035
1036    /// Spawns the remote worker on its own OS thread with a current-thread runtime
1037    /// (the push receive is blocking), connecting IN to the production listener.
1038    struct WorkerThread {
1039        stop: Arc<std::sync::atomic::AtomicBool>,
1040        handle: Option<std::thread::JoinHandle<()>>,
1041    }
1042
1043    impl WorkerThread {
1044        fn spawn(address: String, config: WorkerConfig, registry: Arc<ActivityRegistry>) -> Self {
1045            let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
1046            let thread_stop = Arc::clone(&stop);
1047            let handle = std::thread::spawn(move || {
1048                let runtime = match tokio::runtime::Builder::new_current_thread()
1049                    .enable_all()
1050                    .build()
1051                {
1052                    Ok(runtime) => runtime,
1053                    Err(error) => {
1054                        eprintln!("worker runtime build failed: {error}");
1055                        return;
1056                    }
1057                };
1058                runtime.block_on(async move {
1059                    let worker = match LiminalActivityWorker::connect(&address, &config, registry) {
1060                        Ok(worker) => worker,
1061                        Err(error) => {
1062                            eprintln!("worker connect failed: {error}");
1063                            return;
1064                        }
1065                    };
1066                    if let Err(error) = worker
1067                        .serve_until(|| thread_stop.load(Ordering::SeqCst))
1068                        .await
1069                    {
1070                        eprintln!("worker serve loop ended with error: {error}");
1071                    }
1072                });
1073            });
1074            Self {
1075                stop,
1076                handle: Some(handle),
1077            }
1078        }
1079
1080        /// Spawn the worker through [`aion_worker::serve_with_redial`] — the entry
1081        /// point every REAL worker uses — so a broken link is survivable.
1082        ///
1083        /// [`Self::spawn`] uses `LiminalActivityWorker::serve_until`, which returns
1084        /// the first transport error by design: a single-connection serve has no
1085        /// survivor to migrate to. That is the right shape for a test whose link
1086        /// never breaks, and the wrong instrument entirely for one whose link is
1087        /// broken on purpose — a worker that dies at the break can only ever show
1088        /// that outstanding work fails, whoever is at fault.
1089        ///
1090        /// The redial driver is SYNCHRONOUS and builds its own current-thread
1091        /// runtime, so it runs on the bare thread rather than inside one.
1092        fn spawn_redialing(
1093            address: String,
1094            config: WorkerConfig,
1095            registry: Arc<ActivityRegistry>,
1096            timing: aion_worker::RedialTiming,
1097        ) -> Self {
1098            let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
1099            let thread_stop = Arc::clone(&stop);
1100            let handle = std::thread::spawn(move || {
1101                if let Err(error) = aion_worker::serve_with_redial(
1102                    vec![address],
1103                    &config,
1104                    &registry,
1105                    timing,
1106                    &thread_stop,
1107                    None,
1108                    || {},
1109                ) {
1110                    eprintln!("redialing worker ended with error: {error}");
1111                }
1112            });
1113            Self {
1114                stop,
1115                handle: Some(handle),
1116            }
1117        }
1118
1119        fn stop(mut self) {
1120            self.stop.store(true, Ordering::SeqCst);
1121            if let Some(handle) = self.handle.take() {
1122                handle.join().ok();
1123            }
1124        }
1125    }
1126
1127    fn count_completed(history: &[Event]) -> usize {
1128        history
1129            .iter()
1130            .filter(|event| matches!(event, Event::ActivityCompleted { .. }))
1131            .count()
1132    }
1133
1134    fn count_workflow_completed(history: &[Event]) -> usize {
1135        history
1136            .iter()
1137            .filter(|event| matches!(event, Event::WorkflowCompleted { .. }))
1138            .count()
1139    }
1140
1141    async fn wait_for_history<F>(
1142        store: &dyn aion_store::ReadableEventStore,
1143        workflow_id: &aion_core::WorkflowId,
1144        description: &str,
1145        predicate: F,
1146    ) -> Result<Vec<Event>, TestError>
1147    where
1148        F: Fn(&[Event]) -> bool,
1149    {
1150        let deadline = Instant::now() + POLL_DEADLINE;
1151        loop {
1152            let history = store.read_history(workflow_id).await.map_err(test_error)?;
1153            if predicate(&history) {
1154                return Ok(history);
1155            }
1156            if Instant::now() > deadline {
1157                return Err(test_error(format!(
1158                    "timed out waiting for {description}: {history:#?}"
1159                )));
1160            }
1161            tokio::time::sleep(Duration::from_millis(25)).await;
1162        }
1163    }
1164
1165    /// Start the loaded `collect_four` workflow over the REAL HTTP transport.
1166    async fn start_over_http(router: &axum::Router) -> Result<aion_core::WorkflowId, TestError> {
1167        let build_request = || -> Result<Request<body::Body>, TestError> {
1168            Request::builder()
1169                .uri("/workflows/start")
1170                .method("POST")
1171                .header("content-type", "application/json")
1172                .header("x-aion-subject", "ci")
1173                .header("x-aion-namespaces", NAMESPACE)
1174                .body(body::Body::from(
1175                    serde_json::to_vec(&json!({
1176                        "namespace": NAMESPACE,
1177                        "workflow_type": OUTBOX_MODULE,
1178                        "input": { "fixture": "input" },
1179                    }))
1180                    .map_err(test_error)?,
1181                ))
1182                .map_err(test_error)
1183        };
1184        let response = router
1185            .clone()
1186            .oneshot(build_request()?)
1187            .await
1188            .map_err(test_error)?;
1189        let status = response.status();
1190        let bytes = body::to_bytes(response.into_body(), usize::MAX)
1191            .await
1192            .map_err(test_error)?
1193            .to_vec();
1194        if status != StatusCode::OK {
1195            return Err(test_error(format!(
1196                "workflow start over HTTP must succeed, got {status}: {}",
1197                String::from_utf8_lossy(&bytes)
1198            )));
1199        }
1200        let body: serde_json::Value = serde_json::from_slice(&bytes).map_err(test_error)?;
1201        // The HTTP wire contract (`clean_dtos::StartWorkflowResponse`) serializes
1202        // `workflow_id` as a plain UUID string, not a nested `{ uuid }` object.
1203        let workflow_id = body["workflow_id"]
1204            .as_str()
1205            .ok_or_else(|| test_error("start response missing workflow id"))?
1206            .parse::<uuid::Uuid>()
1207            .map_err(test_error)?;
1208        Ok(aion_core::WorkflowId::new(workflow_id))
1209    }
1210
1211    /// How long a freshly connected worker needs before the dispatch path may
1212    /// select it, DERIVED from the same two facts the server derives it from.
1213    ///
1214    /// A worker is dispatch-ineligible until it serves an OPENING PROBATION:
1215    /// [`Reachability::is_proved`] requires `DISPATCH_PROBATION_PINGS` consecutive
1216    /// answered liveness pings, at the probe's cadence of
1217    /// [`sweep_interval`](crate::worker::sweep_interval)`(heartbeat_window)`. The
1218    /// constant's own documentation states the cost — *"at the probe's cadence a
1219    /// fresh worker is undispatchable for K cadences while its first dispatches
1220    /// park"* — so this is designed behaviour a test must wait out, not a delay to
1221    /// be shortened.
1222    ///
1223    /// One extra cadence is allowed because the first round lands at an arbitrary
1224    /// offset inside the first interval: the worker connects between rounds, so it
1225    /// can miss up to one whole cadence before its first answer is even counted.
1226    ///
1227    /// # Why this is not a raised timeout
1228    ///
1229    /// It was 5 seconds, fixed, and that is how this test became one of four
1230    /// documented carriers of a load-sensitive flake
1231    /// (`gate-logs/lock-race-attribution/VERDICT.md`). The mechanism, measured:
1232    /// `dispatch_ineligible` starts EMPTY and `select_worker` filters only against
1233    /// what the probe has published, so a run in which **no probe round lands
1234    /// inside the window** selects the worker immediately and passes, while a run
1235    /// in which one does correctly withholds it for ~2 cadences and fails. On the
1236    /// default 30s window that is 7.5s per cadence against a 5s wait.
1237    ///
1238    /// 🔴 The passing runs were the WRONG ones. They dispatched to a worker that
1239    /// had not served its probation — a path production does not permit, because
1240    /// production parks those dispatches. Waiting for genuine eligibility makes
1241    /// this test MORE production-shaped, not more lenient, and that is the reason
1242    /// to do it. Raising a bound until a flake stops is how a liveness bug gets
1243    /// buried; deriving the bound from the mechanism that sets it is not the same
1244    /// act, and the register warns about the first for good reason.
1245    fn eligibility_patience(config: &ServerConfig) -> Duration {
1246        let cadence = crate::worker::sweep_interval(config.worker.heartbeat_window);
1247        cadence * (crate::worker::heartbeat::DISPATCH_PROBATION_PINGS + 1)
1248    }
1249
1250    /// Wait until the worker's in-band registration lands in the SAME registry the
1251    /// dispatch path selects from, with every fan-out activity type eligible.
1252    ///
1253    /// On the deadline this reports the state that DISCRIMINATES the worlds a
1254    /// missed registration can be in, because the bare sentence it replaced —
1255    /// "worker never registered in-band for the pool" — is equally true in at
1256    /// least three of them, and they want different fixes:
1257    ///
1258    /// 1. the liminal listener never bound, so nothing could dial in;
1259    /// 2. the worker never connected, or died dialling;
1260    /// 3. it connected and registration was merely slow;
1261    /// 4. it connected, registered correctly, and the SELECTOR refused it anyway —
1262    ///    because the liveness probe published it as unreachable, or because it is
1263    ///    not indexed for the activity type it advertises.
1264    ///
1265    /// The fourth was not in the first version of this report, and it is the world
1266    /// a real occurrence turned out to be in: the listener was bound, a worker was
1267    /// registered under the right namespace and queue advertising all four activity
1268    /// types, and every `select_worker` still returned nothing. A report that
1269    /// cannot separate "not registered" from "registered and refused" names the
1270    /// wrong half of the system.
1271    ///
1272    /// That is not a hypothetical distinction here. This module's e2e is one of
1273    /// four documented carriers of a load-sensitive flake
1274    /// (`gate-logs/lock-race-attribution/VERDICT.md`), it fails through THIS wait,
1275    /// and the reason the carrier has never been explained is that the failure
1276    /// named the fact and withheld the cause.
1277    async fn wait_for_registration(
1278        registry: &crate::worker::ConnectedWorkerRegistry,
1279        heartbeat: &crate::worker::HeartbeatTracker,
1280        listen_address: SocketAddr,
1281        patience: Duration,
1282    ) -> Result<(), TestError> {
1283        let deadline = Instant::now() + patience;
1284        loop {
1285            let now = Instant::now();
1286            let mut ready = true;
1287            for activity_type in FAN_ACTIVITY_TYPES {
1288                let Some(worker) = registry
1289                    .select_worker(NAMESPACE, TASK_QUEUE, activity_type, None)
1290                    .map_err(test_error)?
1291                else {
1292                    ready = false;
1293                    break;
1294                };
1295                if !heartbeat
1296                    .is_dispatch_reachable(worker.id(), now)
1297                    .map_err(test_error)?
1298                {
1299                    ready = false;
1300                    break;
1301                }
1302            }
1303            if ready {
1304                return Ok(());
1305            }
1306            if Instant::now() > deadline {
1307                return Err(test_error(format!(
1308                    "worker never registered in-band for the pool within {patience:?}{}",
1309                    registration_diagnosis(registry, listen_address)
1310                )));
1311            }
1312            tokio::time::sleep(Duration::from_millis(10)).await;
1313        }
1314    }
1315
1316    /// The discriminator behind [`wait_for_registration`]'s failure: enough of the
1317    /// world to tell those three apart, gathered at the moment of the failure.
1318    fn registration_diagnosis(
1319        registry: &crate::worker::ConnectedWorkerRegistry,
1320        listen_address: SocketAddr,
1321    ) -> String {
1322        let mut lines = vec![String::from("--- registration diagnosis ---")];
1323        // World 1, PROBED rather than assumed. The port was reserved by binding a
1324        // listener and dropping it, so losing the race for it is a real
1325        // possibility rather than a theoretical one, and it is indistinguishable
1326        // from every other failure unless something asks.
1327        lines.push(
1328            match std::net::TcpStream::connect_timeout(&listen_address, Duration::from_millis(500))
1329            {
1330                Ok(stream) => {
1331                    drop(stream);
1332                    format!("listener {listen_address}: ACCEPTS — the port is bound and dialable")
1333                }
1334                Err(error) => format!(
1335                    "listener {listen_address}: NOT connectable ({error}) — nothing could have \
1336                     registered, so this is not a timing problem"
1337                ),
1338            },
1339        );
1340        // Worlds 2 and 3: did any worker arrive at all, and if one did, what does
1341        // the registry hold for it against what the dispatch path asks of it? A
1342        // worker present under a different pool or advertising different activity
1343        // types is a contract mismatch wearing a timeout's clothes.
1344        match registry.all_workers() {
1345            Err(error) => lines.push(format!("registry: UNREADABLE ({error})")),
1346            Ok(workers) if workers.is_empty() => lines.push(String::from(
1347                "registry: EMPTY — no worker of any pool registered, so no connection ever \
1348                 completed an in-band registration",
1349            )),
1350            Ok(workers) => {
1351                lines.push(format!("registry: {} worker(s) registered", workers.len()));
1352                for worker in &workers {
1353                    lines.push(format!(
1354                        "  id={:?} namespaces={:?} task_queue={:?} node={:?} types={:?}",
1355                        worker.id(),
1356                        worker.namespaces(),
1357                        worker.task_queue(),
1358                        worker.node(),
1359                        worker.activity_types()
1360                    ));
1361                }
1362            }
1363        }
1364        lines.push(format!(
1365            "asked of it: namespace={NAMESPACE:?} task_queue={TASK_QUEUE:?}"
1366        ));
1367        // The liveness probe's reachability verdict. `select_worker` skips every
1368        // worker in this set, so a registered, correctly-advertised worker that is
1369        // listed here is refused for a reason nothing else in this report shows.
1370        lines.push(match registry.dispatch_ineligible() {
1371            Ok(ineligible) if ineligible.is_empty() => {
1372                String::from("dispatch-ineligible: none — reachability is not refusing anyone")
1373            }
1374            Ok(ineligible) => format!(
1375                "dispatch-ineligible: {ineligible:?} — the liveness probe has published these \
1376                 as unreachable and select_worker skips them"
1377            ),
1378            Err(error) => format!("dispatch-ineligible: UNREADABLE ({error})"),
1379        });
1380        // Which of the four the selector could not satisfy, and — the part that
1381        // discriminates — the pool census beside each refusal.
1382        //
1383        // `select_worker` filters on THREE things: the activity index for
1384        // `(namespace, task_queue) + activity_type`, the node pin, and the
1385        // dispatch-ineligible set. The census counts the first two and does NOT
1386        // apply the third, so the pair of answers separates the remaining worlds
1387        // that a registry dump alone leaves fused:
1388        //
1389        // - census serves it, selector refuses  ⇒ REACHABILITY, not registration;
1390        // - census serves 0 for the activity    ⇒ the worker is in the pool but not
1391        //   indexed for this activity type;
1392        // - census serves 0 for the pool        ⇒ it is not in this pool at all,
1393        //   whatever `all_workers` shows.
1394        //
1395        // Written after the bare registry dump above failed to close a real case:
1396        // it proved the listener was bound and a worker with all four activity
1397        // types was registered, and still could not say why every selection
1398        // returned nothing.
1399        for activity_type in FAN_ACTIVITY_TYPES {
1400            let outcome = match registry.select_worker(NAMESPACE, TASK_QUEUE, activity_type, None) {
1401                Ok(Some(handle)) => format!("worker {:?}", handle.id()),
1402                Ok(None) => String::from("NO worker"),
1403                Err(error) => format!("error: {error}"),
1404            };
1405            let census = match registry.pool_census(NAMESPACE, TASK_QUEUE, activity_type, None) {
1406                Ok(census) => format!(
1407                    "in_pool={} serving_activity={} compatible={} last_compatible_age={:?}",
1408                    census.workers_in_pool,
1409                    census.workers_serving_activity,
1410                    census.compatible_workers,
1411                    census.last_compatible_poller_age
1412                ),
1413                Err(error) => format!("census UNREADABLE ({error})"),
1414            };
1415            lines.push(format!(
1416                "select_worker({activity_type}) -> {outcome}  [census: {census}]"
1417            ));
1418        }
1419        format!("\n  {}", lines.join("\n  "))
1420    }
1421
1422    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1423    async fn production_boot_dispatches_executes_and_records_over_liminal() -> Result<(), TestError>
1424    {
1425        let dir = crate::test_support::private_tempdir().map_err(test_error)?;
1426        let db_path = dir.path().join("aion.db");
1427        let package_path = write_package_archive(dir.path())?;
1428        // The production path binds the CONFIGURED listen address, so commit to a
1429        // concrete reserved loopback port the worker can also dial.
1430        let listen_address = reserve_loopback_port()?;
1431
1432        // (A) Build a real ServerState through the production boot path
1433        // (ServerState::build over a haematite ServerConfig): outbox enabled,
1434        // transport = liminal, the listen address set, collect_four loaded. This
1435        // shares the haematite leaf as the dispatcher's outbox store (the real boot
1436        // store seam) and installs the production ServerOutboxDeliveryCallback over
1437        // the live engine (gated on outbox.enabled).
1438        let config = server_config(&db_path, package_path, listen_address);
1439        let outbox_config = config.outbox.clone();
1440        // Captured before `build` consumes the config: the wait below is derived
1441        // from the very window this server is about to run its liveness probe on.
1442        let patience = eligibility_patience(&config);
1443        let state = ServerState::build(config, &crate::control::StageReporter::detached())
1444            .await
1445            .map_err(test_error)?;
1446
1447        // (B) Drive the EXACT production commissioning function run_server calls:
1448        // it hosts the liminal listener, builds RegistryLiminalDispatch over the
1449        // shared registry + engine callback, and spawns the real OutboxDispatcher.
1450        // Hold the returned listener guard for the test's lifetime, exactly as
1451        // run_server holds it.
1452        let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
1453        // Own-all, generous-default backpressure (single-node e2e): fraction 1 and
1454        // the platform default, so the ceiling never engages — the claim behaves
1455        // exactly as before, proving the production path is byte-identical on default.
1456        let backpressure_settings = BackpressureSettings {
1457            platform_default: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
1458            fraction: crate::worker::OwnedShardFraction::own_all(),
1459        };
1460        let listener_guard = maybe_spawn_outbox_dispatcher(
1461            &state,
1462            &outbox_config,
1463            false,
1464            backpressure_settings,
1465            &shutdown_rx,
1466            "set outbox.liminal_listen_address in the test config",
1467        )
1468        .map_err(test_error)?;
1469
1470        // (C) A REAL remote worker connects IN to the production listener and
1471        // self-registers in-band for the fixture's pool.
1472        let executions = Arc::new(AtomicUsize::new(0));
1473        let worker = WorkerThread::spawn(
1474            listen_address.to_string(),
1475            worker_config()?,
1476            worker_registry(&executions)?,
1477        );
1478
1479        // Wait until the in-band registration landed in the SAME registry the
1480        // dispatch path selects from (every fan-out activity type is eligible).
1481        let registry = state.worker_registry().clone();
1482        if let Err(error) = wait_for_registration(
1483            &registry,
1484            state.heartbeat_tracker(),
1485            listen_address,
1486            patience,
1487        )
1488        .await
1489        {
1490            worker.stop();
1491            return Err(error);
1492        }
1493
1494        // (D) Start collect_four over the REAL HTTP transport: the engine stages
1495        // four pending outbox rows; the production-wired dispatcher claims and
1496        // pushes each to the worker.
1497        let router = http_router(state.clone()).map_err(test_error)?;
1498        let workflow_id = start_over_http(&router).await?;
1499
1500        // (E) THE PROOF: the worker executed all four activities AND every terminal
1501        // was recorded through the production engine callback (record_fan_out_completion)
1502        // — four ActivityCompleted + one WorkflowCompleted in durable history. This
1503        // is the full round-trip the retired stub never achieved.
1504        let reader = state.engine().map_err(test_error)?.store();
1505        let settled =
1506            wait_for_history(reader.as_ref(), &workflow_id, "fan-out settled", |events| {
1507                count_completed(events) == FAN_OUT && count_workflow_completed(events) == 1
1508            })
1509            .await?;
1510        assert_eq!(
1511            count_completed(&settled),
1512            FAN_OUT,
1513            "every fan-out member must record a terminal through the production callback"
1514        );
1515        assert_eq!(
1516            count_workflow_completed(&settled),
1517            1,
1518            "the workflow must complete exactly once"
1519        );
1520        assert_eq!(
1521            executions.load(Ordering::SeqCst),
1522            FAN_OUT,
1523            "the remote worker must have executed every pushed dispatch exactly once"
1524        );
1525
1526        // Teardown: stop the dispatcher + worker, drop the listener guard (its Drop
1527        // stops the accept worker), shut the engine down so durable appends finish.
1528        shutdown_tx.send(true).ok();
1529        worker.stop();
1530        drop(listener_guard);
1531        state.shutdown().map_err(test_error)?;
1532        Ok(())
1533    }
1534
1535    /// One dispatch as the WORKER saw it: the identity the server sent it under,
1536    /// and when it arrived.
1537    #[derive(Clone, Debug)]
1538    struct SeenDispatch {
1539        activity_type: String,
1540        activity_id: String,
1541        attempt: u32,
1542        at: Instant,
1543    }
1544
1545    /// A loopback TCP relay the test can BREAK, sitting between the worker and the
1546    /// production liminal listener.
1547    ///
1548    /// The worker dials this instead of the listener, so the test owns a socket it
1549    /// can shut from the outside. That is the only way to make a REAL
1550    /// [`LiminalActivityWorker`] lose its connection mid-flight without reaching
1551    /// inside either the worker or the server — and a link broken from the inside
1552    /// would be a different experiment, because the code under test would be the
1553    /// code doing the breaking.
1554    ///
1555    /// # Why this is not the relay in `tests/dead_man_switch_e2e.rs`
1556    ///
1557    /// That file has `WedgeableRelay`, which can both wedge and sever, and this is
1558    /// deliberately not it. The two cannot be one, for a structural reason rather
1559    /// than a matter of taste: an integration test links this crate as an ordinary
1560    /// dependency, so it can see neither `#[cfg(test)] pub(crate) mod test_support`
1561    /// nor the private `maybe_spawn_outbox_dispatcher` this harness is built on,
1562    /// and `src/` cannot see `tests/`. Sharing one instrument would mean exporting
1563    /// a public, feature-gated test surface from a production crate.
1564    ///
1565    /// So the split is stated rather than hidden, and this half is a strict subset:
1566    /// it only severs. Wedging — which leaves both sockets open and merely discards
1567    /// bytes, so writes keep succeeding into the kernel buffer — is a DIFFERENT
1568    /// instrument answering a different question. #69 is about a broken link, not
1569    /// a silent one.
1570    struct SeverableRelay {
1571        address: SocketAddr,
1572        /// Every relayed socket, held so [`Self::sever`] can break them.
1573        sockets: Arc<std::sync::Mutex<Vec<std::net::TcpStream>>>,
1574        stop: Arc<std::sync::atomic::AtomicBool>,
1575        handle: Option<std::thread::JoinHandle<()>>,
1576    }
1577
1578    impl SeverableRelay {
1579        /// Bind a loopback port and relay every accepted connection to `upstream`.
1580        fn spawn(upstream: SocketAddr) -> Result<Self, TestError> {
1581            let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(test_error)?;
1582            let address = listener.local_addr().map_err(test_error)?;
1583            // Non-blocking accept so the relay can be shut down deterministically
1584            // rather than by parking a thread in `accept` until something happens
1585            // to connect. Accepted sockets are put back into blocking mode
1586            // explicitly: on this platform they would otherwise inherit the flag
1587            // and every pump would spin on `WouldBlock`.
1588            listener.set_nonblocking(true).map_err(test_error)?;
1589            let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
1590            let sockets: Arc<std::sync::Mutex<Vec<std::net::TcpStream>>> =
1591                Arc::new(std::sync::Mutex::new(Vec::new()));
1592            let accept_stop = Arc::clone(&stop);
1593            let accept_sockets = Arc::clone(&sockets);
1594            let handle = std::thread::spawn(move || {
1595                while !accept_stop.load(Ordering::SeqCst) {
1596                    match listener.accept() {
1597                        Ok((downstream, _)) => {
1598                            if let Err(error) =
1599                                Self::relay_one(&downstream, upstream, &accept_sockets)
1600                            {
1601                                // The worker redials, so a connection this relay
1602                                // fails to carry surfaces as a slower recovery
1603                                // rather than as a wrong answer — but silence here
1604                                // would make that indistinguishable from the
1605                                // server never pushing, which is exactly the
1606                                // confusion this pin exists to resolve.
1607                                eprintln!("relay could not carry a connection: {error}");
1608                            }
1609                        }
1610                        Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
1611                            std::thread::sleep(Duration::from_millis(2));
1612                        }
1613                        Err(error) => {
1614                            eprintln!("relay accept failed: {error}");
1615                            return;
1616                        }
1617                    }
1618                }
1619            });
1620            Ok(Self {
1621                address,
1622                sockets,
1623                stop,
1624                handle: Some(handle),
1625            })
1626        }
1627
1628        /// Dial upstream for one accepted connection and pump both directions.
1629        fn relay_one(
1630            downstream: &std::net::TcpStream,
1631            upstream: SocketAddr,
1632            sockets: &Arc<std::sync::Mutex<Vec<std::net::TcpStream>>>,
1633        ) -> Result<(), TestError> {
1634            downstream.set_nonblocking(false).map_err(test_error)?;
1635            let up = std::net::TcpStream::connect(upstream).map_err(test_error)?;
1636            let down_read = downstream.try_clone().map_err(test_error)?;
1637            let down_write = downstream.try_clone().map_err(test_error)?;
1638            let up_read = up.try_clone().map_err(test_error)?;
1639            let up_write = up.try_clone().map_err(test_error)?;
1640            let held = downstream.try_clone().map_err(test_error)?;
1641            let mut parked = sockets
1642                .lock()
1643                .map_err(|_| test_error("relay socket register poisoned"))?;
1644            parked.push(held);
1645            parked.push(up);
1646            drop(parked);
1647            for (from, to) in [(down_read, up_write), (up_read, down_write)] {
1648                std::thread::spawn(move || Self::pump(from, to));
1649            }
1650            Ok(())
1651        }
1652
1653        /// Copy one direction until the connection ends.
1654        ///
1655        /// A read or write error here IS the severed link in the expected case, and
1656        /// in every case it means the peer this pump exists to serve is gone: there
1657        /// is no party left to propagate to, so ending the pump is the handling,
1658        /// not an omission of it.
1659        fn pump(mut from: std::net::TcpStream, mut to: std::net::TcpStream) {
1660            use std::io::{Read, Write};
1661            let mut buffer = [0_u8; 8192];
1662            loop {
1663                match from.read(&mut buffer) {
1664                    Ok(0) | Err(_) => return,
1665                    Ok(read) => {
1666                        if to.write_all(&buffer[..read]).is_err() {
1667                            return;
1668                        }
1669                    }
1670                }
1671            }
1672        }
1673
1674        const fn address(&self) -> SocketAddr {
1675            self.address
1676        }
1677
1678        /// BREAK every relayed socket, and report how many were broken.
1679        ///
1680        /// The count is returned, and asserted non-zero by the caller, so that a
1681        /// sever which severed nothing can never masquerade as a measurement — the
1682        /// pin would otherwise pass by never having run its own experiment.
1683        fn sever(&self) -> Result<usize, TestError> {
1684            let mut parked = self
1685                .sockets
1686                .lock()
1687                .map_err(|_| test_error("relay socket register poisoned"))?;
1688            let mut severed = 0;
1689            for socket in parked.iter() {
1690                if socket.shutdown(std::net::Shutdown::Both).is_ok() {
1691                    severed += 1;
1692                }
1693            }
1694            parked.clear();
1695            Ok(severed)
1696        }
1697
1698        fn shutdown(mut self) {
1699            self.stop.store(true, Ordering::SeqCst);
1700            if let Some(handle) = self.handle.take() {
1701                handle.join().ok();
1702            }
1703        }
1704    }
1705
1706    /// Registry for the reconnect pin: every dispatch is RECORDED with the identity
1707    /// the server sent it under, and [`HELD_ACTIVITY_TYPE`]'s FIRST dispatch holds
1708    /// — the work is finished, its reply is not yet on the wire — until released.
1709    ///
1710    /// Only the first is held. A blanket hold would stall the re-delivery this pin
1711    /// exists to observe, and the pin would then measure its own instrument.
1712    fn recording_registry(
1713        seen: &Arc<std::sync::Mutex<Vec<SeenDispatch>>>,
1714        release: &Arc<std::sync::atomic::AtomicBool>,
1715    ) -> Result<Arc<ActivityRegistry>, TestError> {
1716        let mut registry = ActivityRegistry::new();
1717        for activity_type in FAN_ACTIVITY_TYPES {
1718            let seen = Arc::clone(seen);
1719            let release = Arc::clone(release);
1720            let arrivals = Arc::new(AtomicUsize::new(0));
1721            registry = registry
1722                .register_activity_with_contract(
1723                    activity_type,
1724                    move |_input: FanInput, context: &aion_worker::ActivityContext| {
1725                        let seen = Arc::clone(&seen);
1726                        let release = Arc::clone(&release);
1727                        let arrivals = Arc::clone(&arrivals);
1728                        let record = SeenDispatch {
1729                            activity_type: activity_type.to_owned(),
1730                            activity_id: context.activity_id().to_string(),
1731                            attempt: context.attempt(),
1732                            at: Instant::now(),
1733                        };
1734                        Box::pin(async move {
1735                            // Recorded BEFORE the hold: a dispatch that arrives and
1736                            // is never answered must still be visible, or the pin
1737                            // cannot tell "never re-delivered" from "re-delivered
1738                            // and lost again".
1739                            match seen.lock() {
1740                                Ok(mut log) => log.push(record),
1741                                Err(_) => {
1742                                    return Err(aion_worker::ActivityFailure::terminal(
1743                                        "the pin's dispatch log is poisoned, so this run can \
1744                                         observe nothing — failing loudly rather than \
1745                                         returning a result no assertion could trust",
1746                                    ));
1747                                }
1748                            }
1749                            let first = arrivals.fetch_add(1, Ordering::SeqCst) == 0;
1750                            if activity_type == HELD_ACTIVITY_TYPE && first {
1751                                while !release.load(Ordering::SeqCst) {
1752                                    tokio::time::sleep(Duration::from_millis(5)).await;
1753                                }
1754                            }
1755                            Ok(activity_type.to_owned())
1756                        })
1757                    },
1758                )
1759                .map_err(test_error)?;
1760        }
1761        Ok(Arc::new(registry))
1762    }
1763
1764    fn dispatches_of(
1765        seen: &Arc<std::sync::Mutex<Vec<SeenDispatch>>>,
1766        activity_type: &str,
1767    ) -> Result<Vec<SeenDispatch>, TestError> {
1768        let log = seen
1769            .lock()
1770            .map_err(|_| test_error("the pin's dispatch log is poisoned"))?;
1771        Ok(log
1772            .iter()
1773            .filter(|record| record.activity_type == activity_type)
1774            .cloned()
1775            .collect())
1776    }
1777
1778    fn dispatch_log(seen: &Arc<std::sync::Mutex<Vec<SeenDispatch>>>) -> String {
1779        match seen.lock() {
1780            Ok(log) => format!("{:#?}", *log),
1781            Err(_) => String::from("<poisoned>"),
1782        }
1783    }
1784
1785    /// aion #69 at the ENGINE level: what the system DOES after an activity's
1786    /// completion is lost to a broken link.
1787    ///
1788    /// # What this measures, and why the transport-level pin cannot
1789    ///
1790    /// #69's existing red-first pin lives on its fix branch rather than here (it
1791    /// is red on purpose and lands with the fix), and it establishes that the
1792    /// completion is DISCARDED: the server abandons the correlated reply-wait the
1793    /// moment the delivering connection closes. It drives `WorkerDelivery`
1794    /// directly, with no engine, no store and no workflow behind it, so it can say
1795    /// nothing at all about what happens NEXT. That gap is the whole severity of
1796    /// #69: "the work is repeated once" and "the work is lost" are priced very
1797    /// differently, and nothing in-tree could tell them apart.
1798    ///
1799    /// So this pin observes four things, and asserts only what must hold in EVERY
1800    /// world — including the one a #69 fix creates:
1801    ///
1802    /// - **O4, ASSERTED** — the workflow still reaches a recorded terminal. This is
1803    ///   the invariant: a broken link must not cost the workflow. It is not a weak
1804    ///   assertion, because `collect_four` consumes all four members, so the
1805    ///   workflow cannot complete while any member's work is missing;
1806    /// - **O1, REPORTED** — whether the held activity is dispatched a SECOND time.
1807    ///   This is the MECHANISM, and the mechanism is what a fix changes: a fix that
1808    ///   carries the completion across the reconnect would produce NO re-delivery,
1809    ///   and a pin asserting one would read that fix as a regression.
1810    ///   regression;
1811    /// - **O2, asserted CONDITIONALLY** — if a re-delivery happened it must carry
1812    ///   the activity's OWN identity. That is what makes the finished work
1813    ///   discarded rather than recovered; a re-delivery under a different identity
1814    ///   is a different defect and must not pass quietly;
1815    /// - **O3, REPORTED** — the elapsed time from the break to the re-delivery, as
1816    ///   a NUMBER asserted against nothing. No threshold is invented here: the
1817    ///   right bound is a conversation to have with the measurement in hand.
1818    ///
1819    /// ⚠️ **O3 is recovery LATENCY, and latency is not COST.** The number is
1820    /// measured on a fixture activity that is a pure `String -> String`, so its
1821    /// repeat costs microseconds. The real cost of a repeat is the repeated
1822    /// activity's own runtime plus its repeated SIDE EFFECTS, which this pin does
1823    /// not measure and structurally cannot: #69's own exhibit was an *agent*
1824    /// activity, whose repeat is minutes of compute and files written twice.
1825    /// Quote the finding — *repeated work, not lost work, one repeat per in-flight
1826    /// activity* — rather than the milliseconds, which carry their premise (a
1827    /// trivial activity) only for as long as someone remembers to attach it.
1828    ///
1829    /// The settle-wait below is bounded by [`POLL_DEADLINE`], so this pin cannot
1830    /// hang; but that bound is ~100x the observed recovery, so it is a liveness
1831    /// guard and NOT a latency guard. A large latency regression would still pass
1832    /// here, reported in O3 and asserted by nothing — deliberately, because the
1833    /// correct bound is not derivable from the samples taken so far.
1834    ///
1835    /// Executions are REPORTED, never asserted equal to the fan-out. A transport
1836    /// that can lose a reply gives at-least-once delivery, so the sibling test's
1837    /// `executions == FAN_OUT` is the wrong shape here and must not be copied
1838    /// across.
1839    ///
1840    /// # The world this models
1841    ///
1842    /// One server process with its transport-loss ledger live in memory, a worker
1843    /// that redials the SAME address, and a SINGLE loss — well inside
1844    /// `TRANSPORT_LOSS_BUDGET_WINDOWS`. It is NOT a server restart and NOT budget
1845    /// exhaustion, both of which are different worlds with different recoveries.
1846    /// The re-delivery this venue can produce is the outbox dispatcher's re-claim
1847    /// under the `max_attempts`/backoff this test's config sets, not the #266
1848    /// recovery replay — which is what gives O3's number a slot to mean anything in.
1849    ///
1850    /// The relay's own accept poll (2ms) sits inside the measured elapsed.
1851    ///
1852    /// ⚠️ This pin shares a venue with
1853    /// `production_boot_dispatches_executes_and_records_over_liminal`, one of four
1854    /// documented carriers of a load-sensitive flake — 2/24 on a base that
1855    /// predates it (`gate-logs/lock-race-attribution/VERDICT.md`). It inherits that
1856    /// sensitivity, and a red here should be read against that register first.
1857    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1858    async fn a_completion_lost_to_a_severed_link_is_re_dispatched_and_the_workflow_settles()
1859    -> Result<(), TestError> {
1860        let dir = crate::test_support::private_tempdir().map_err(test_error)?;
1861        let db_path = dir.path().join("aion.db");
1862        let package_path = write_package_archive(dir.path())?;
1863        let listen_address = reserve_loopback_port()?;
1864
1865        let config = server_config(&db_path, package_path, listen_address);
1866        let outbox_config = config.outbox.clone();
1867        // Captured before `build` consumes the config: the wait below is derived
1868        // from the very window this server is about to run its liveness probe on.
1869        let patience = eligibility_patience(&config);
1870        let state = ServerState::build(config, &crate::control::StageReporter::detached())
1871            .await
1872            .map_err(test_error)?;
1873        let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
1874        let backpressure_settings = BackpressureSettings {
1875            platform_default: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
1876            fraction: crate::worker::OwnedShardFraction::own_all(),
1877        };
1878        let listener_guard = maybe_spawn_outbox_dispatcher(
1879            &state,
1880            &outbox_config,
1881            false,
1882            backpressure_settings,
1883            &shutdown_rx,
1884            "set outbox.liminal_listen_address in the test config",
1885        )
1886        .map_err(test_error)?;
1887
1888        // The worker dials the RELAY, which carries it to the production listener.
1889        let relay = SeverableRelay::spawn(listen_address)?;
1890        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1891        let release = Arc::new(std::sync::atomic::AtomicBool::new(false));
1892        // The redial timings are the ones this module's `worker_config` already
1893        // declares, read off it rather than re-chosen here: a reconnect pin that
1894        // picked its own recovery timings would be measuring a world of its own.
1895        let config = worker_config()?;
1896        let timing = aion_worker::RedialTiming::new(
1897            config.reconnect.initial_backoff,
1898            config.reconnect.max_backoff,
1899        );
1900        let worker = WorkerThread::spawn_redialing(
1901            relay.address().to_string(),
1902            config,
1903            recording_registry(&seen, &release)?,
1904            timing,
1905        );
1906
1907        let outcome =
1908            observe_reconnect(&state, &relay, &seen, &release, listen_address, patience).await;
1909
1910        // Teardown runs on EVERY path, including a failing one: a leaked worker
1911        // thread or listener poisons whatever runs next, and this venue is already
1912        // load-sensitive enough without the pin adding to it.
1913        shutdown_tx.send(true).ok();
1914        release.store(true, Ordering::SeqCst);
1915        worker.stop();
1916        relay.shutdown();
1917        drop(listener_guard);
1918        state.shutdown().map_err(test_error)?;
1919        outcome
1920    }
1921
1922    /// The measurement behind
1923    /// [`a_completion_lost_to_a_severed_link_is_re_dispatched_and_the_workflow_settles`],
1924    /// split out so its many early returns cannot skip the harness teardown.
1925    /// Wait until the held member is dispatched and holding — the moment the link
1926    /// can be broken — and report how many of its siblings had already settled.
1927    ///
1928    /// The split at the break is REPORTED, never required. An earlier draft
1929    /// demanded that the other three settle first, for a single-variable
1930    /// experiment. Measured across runs it simply varies: the four pushes land
1931    /// within microseconds of each other and which records a terminal first is a
1932    /// race, so requiring a particular split would fail the pin for a reason that
1933    /// has nothing to do with what it measures.
1934    async fn await_held_dispatch(
1935        reader: &dyn aion_store::ReadableEventStore,
1936        workflow_id: &aion_core::WorkflowId,
1937        seen: &Arc<std::sync::Mutex<Vec<SeenDispatch>>>,
1938    ) -> Result<(SeenDispatch, usize), TestError> {
1939        let deadline = Instant::now() + POLL_DEADLINE;
1940        loop {
1941            if let Some(first) = dispatches_of(seen, HELD_ACTIVITY_TYPE)?.first() {
1942                let at_the_break = reader.read_history(workflow_id).await.map_err(test_error)?;
1943                return Ok((first.clone(), count_completed(&at_the_break)));
1944            }
1945            if Instant::now() > deadline {
1946                let history = reader.read_history(workflow_id).await.map_err(test_error)?;
1947                return Err(test_error(format!(
1948                    "{HELD_ACTIVITY_TYPE} was never dispatched at all within {POLL_DEADLINE:?}, \
1949                     so there was no held completion to lose and this run measured nothing.\n\
1950                     dispatch log: {}\nhistory: {history:#?}",
1951                    dispatch_log(seen),
1952                )));
1953            }
1954            tokio::time::sleep(Duration::from_millis(25)).await;
1955        }
1956    }
1957
1958    async fn observe_reconnect(
1959        state: &ServerState,
1960        relay: &SeverableRelay,
1961        seen: &Arc<std::sync::Mutex<Vec<SeenDispatch>>>,
1962        release: &Arc<std::sync::atomic::AtomicBool>,
1963        listen_address: SocketAddr,
1964        patience: Duration,
1965    ) -> Result<(), TestError> {
1966        wait_for_registration(
1967            state.worker_registry(),
1968            state.heartbeat_tracker(),
1969            listen_address,
1970            patience,
1971        )
1972        .await?;
1973
1974        let router = http_router(state.clone()).map_err(test_error)?;
1975        let workflow_id = start_over_http(&router).await?;
1976        let reader = state.engine().map_err(test_error)?.store();
1977
1978        let (first, settled_before) =
1979            await_held_dispatch(reader.as_ref(), &workflow_id, seen).await?;
1980
1981        // BREAK the link while the finished work is still holding its reply.
1982        let severed = relay.sever()?;
1983        let severed_at = Instant::now();
1984        if severed == 0 {
1985            return Err(test_error(
1986                "the relay severed NOTHING, so no link was ever broken and this run measured \
1987                 nothing — a pass here would have been an artefact of the instrument",
1988            ));
1989        }
1990        // Release the hold: the worker now writes its reply into a dead socket.
1991        release.store(true, Ordering::SeqCst);
1992
1993        // O4 FIRST, because it is the INVARIANT: a broken link must not cost the
1994        // workflow. Every other observable here describes the MECHANISM by which
1995        // that holds, and the mechanism is exactly what a #69 fix is expected to
1996        // change — so asserting today's mechanism would make the fix read as a
1997        // regression, and would be asserting the enumeration rather than the
1998        // invariant.
1999        //
2000        // O4 is load-bearing rather than weak because `collect_four` CONSUMES all
2001        // four members: the workflow cannot reach a completed terminal while any
2002        // member's work is missing, so "the workflow settled" is not a state that
2003        // silently lost work can also produce.
2004        let settled = wait_for_history(
2005            reader.as_ref(),
2006            &workflow_id,
2007            "the workflow to settle after the severed link",
2008            |events| count_completed(events) == FAN_OUT && count_workflow_completed(events) == 1,
2009        )
2010        .await
2011        .map_err(|error| {
2012            test_error(format!(
2013                "O4 FAILED — the workflow did not settle after the link broke ({severed} \
2014                 socket(s) severed), so the lost completion cost the workflow rather than \
2015                 costing a repeat of the work.\n{error}\ndispatch log: {}",
2016                dispatch_log(seen),
2017            ))
2018        })?;
2019        assert_eq!(
2020            count_completed(&settled),
2021            FAN_OUT,
2022            "every fan-out member must still record a terminal after the link broke"
2023        );
2024        assert_eq!(
2025            count_workflow_completed(&settled),
2026            1,
2027            "the workflow must complete exactly once even though a completion was lost"
2028        );
2029
2030        // O1/O2/O3 — the MECHANISM, reported. O2 is asserted only CONDITIONALLY:
2031        // if a re-delivery happened it must have carried the activity's own
2032        // identity, because a re-delivery under a different identity would be a
2033        // different defect entirely and must not pass quietly. If no re-delivery
2034        // happened, the completion survived the reconnect — which is what a fixed
2035        // #69 looks like, and this pin should report it, not fail on it.
2036        let held = dispatches_of(seen, HELD_ACTIVITY_TYPE)?;
2037        match held.get(1) {
2038            None => println!(
2039                "aion#69 — {HELD_ACTIVITY_TYPE} ({}) was NOT re-dispatched and the workflow \
2040                 still settled, so the held completion survived the break; {settled_before} of \
2041                 {FAN_OUT} members had settled when it broke, {severed} socket(s) severed",
2042                first.activity_id,
2043            ),
2044            Some(second) => {
2045                if second.activity_id != first.activity_id {
2046                    return Err(test_error(format!(
2047                        "O2 FAILED — the re-delivery carried a DIFFERENT activity identity. The \
2048                         first dispatch was {} (attempt {}) and the second was {} (attempt {}), \
2049                         so the work was not re-run under its own identity and #69's framing \
2050                         does not describe what happened here.",
2051                        first.activity_id, first.attempt, second.activity_id, second.attempt,
2052                    )));
2053                }
2054                let recovery = second.at.saturating_duration_since(severed_at);
2055                println!(
2056                    "aion#69 O3 — re-delivery of {} ({}) took {}ms from the link breaking; \
2057                     first attempt {}, second attempt {}; {settled_before} of {FAN_OUT} members \
2058                     had already recorded a terminal when the link broke; {severed} socket(s) \
2059                     severed",
2060                    HELD_ACTIVITY_TYPE,
2061                    first.activity_id,
2062                    recovery.as_millis(),
2063                    first.attempt,
2064                    second.attempt,
2065                );
2066            }
2067        }
2068
2069        let all = seen
2070            .lock()
2071            .map_err(|_| test_error("the pin's dispatch log is poisoned"))?
2072            .len();
2073        println!(
2074            "aion#69 — {all} dispatch(es) served for {FAN_OUT} activities; the transport is \
2075             at-least-once, so the excess is the repeated work a broken link costs"
2076        );
2077        Ok(())
2078    }
2079}