aion_server/worker/liminal_transport.rs
1//! Cross-node outbox dispatch over the liminal bus (LSUB push transport).
2//!
3//! # What this is (production push path)
4//!
5//! This module wires the durable outbox's fan-out dispatch over liminal to a
6//! REAL remote aion worker and returns the worker's result through the existing
7//! [`OutboxDeliveryCallback`](super::bridge::OutboxDeliveryCallback), behind the
8//! `liminal-transport` Cargo feature and the `outbox.transport = liminal`
9//! runtime flag. The aion-server HOSTS the liminal listener: a remote worker
10//! connects IN and self-describes in-band, the server registers it in the SAME
11//! connected-worker registry a gRPC worker joins, and a claimed row is PUSHED out
12//! on the worker's existing connection (the LSUB-0 server-push primitive).
13//!
14//! # Routing (NSTQ-5 / NODE-5)
15//!
16//! A worker is selected by the row's `(namespace, task_queue, activity_type,
17//! node)` pool key through the EXISTING registry `select_worker` โ the same
18//! selection the gRPC path uses, so routing semantics are shared. `activity_type`
19//! is NOT a routing dimension at the wire: it rides inside the [`DispatchRequest`]
20//! payload and is matched by the worker after delivery, exactly as the gRPC
21//! registry pushes `activity_type` in the task body while selecting the worker by
22//! pool key. See `docs/NAMESPACE-TASKQUEUE-SPLIT-DESIGN.md` ยง4.2. The
23//! [`dispatch_channel_name`] derivation remains the single source of truth for the
24//! pool-channel string, pinned for any future channel-subscription subscriber so
25//! the two sides cannot drift.
26//!
27//! # The seams it implements
28//!
29//! - [`LiminalWorkerDelivery`] is the per-worker push handle this module hands
30//! to the registry at registration. Delivering over it is
31//! [`LiminalTaskDelivery`](super::liminal_task_delivery::LiminalTaskDelivery),
32//! the liminal arm of the shared delivery seam, which pushes the
33//! [`DispatchRequest`] out on the worker's existing connection and re-enters
34//! the worker's [`DispatchResponse`] through the SAME
35//! [`LiminalCompletionSource`] / [`OutboxDeliveryCallback`] the gRPC
36//! completion path uses.
37//!
38//! ๐ด This module does NOT select a worker and no longer implements
39//! [`OutboxRowDispatch`](super::outbox_dispatcher::OutboxRowDispatch). It used
40//! to, through a `RegistryLiminalDispatch` that ran its own copy of the
41//! selection โ and a server with `outbox.transport = liminal` therefore
42//! reached EVERY selected worker over liminal, refusing the gRPC-registered
43//! ones it had just selected. That was #52. Selection now happens once, in
44//! [`ActivityDispatcher`](super::dispatch::ActivityDispatcher), and each
45//! chosen worker is served over the transport IT registered on.
46//! - [`LiminalConnectionNotifier`] is the SERVER half of in-band registration:
47//! when a worker connects with a [`WorkerRegistration`](WireWorkerRegistration)
48//! the notifier inserts a [`WorkerDelivery::Liminal`] into the registry, and
49//! drops it on disconnect.
50//! - [`LiminalCompletionSource`] maps a [`DispatchResponse`] onto the delivery
51//! callback, threading `run_id` end-to-end so the existing continue-as-new run
52//! gates apply unchanged.
53//!
54//! # The channel-subscription seam (documented, distinct from the push path)
55//!
56//! [`dispatch_channel_name`] derives the pool channel a `(namespace, task_queue)`
57//! pool addresses, optionally pinned to a `node` (NODE-2/NODE-5). The production
58//! path above does not publish to that channel โ it pushes to a connected worker
59//! the server already owns โ but the derivation is retained as the pinned contract
60//! any future channel-subscription transport MUST honour so the dispatcher and a
61//! subscriber cannot drift:
62//!
63//! - An UNPINNED worker pool addressed `(namespace, task_queue)` subscribes to
64//! `dispatch_channel_name(namespace, task_queue, None)`.
65//! - A NODE-PINNED dispatch (the row carries `Some(node)`) maps to
66//! `dispatch_channel_name(namespace, task_queue, Some(node))` โ a DISTINCT
67//! channel that a worker on that node must ALSO subscribe to in order to serve
68//! pinned work; the unpinned channel alone never delivers a pinned dispatch.
69//!
70//! That is the single contract the seam must honour.
71
72use std::collections::HashMap;
73use std::future::Future;
74use std::sync::{Arc, Mutex, OnceLock};
75use std::time::Duration;
76
77use aion_core::{ActivityId, ContentType, Payload, RunId, WorkflowId};
78use aion_store::OutboxRow;
79use async_trait::async_trait;
80use liminal::protocol::WorkerRegistration as WireWorkerRegistration;
81use liminal_sdk::{SchemaMetadata, SchemaValidate};
82use liminal_server::ServerError as LiminalServerError;
83use liminal_server::server::connection::{
84 ConnectionNotifier, ConnectionSupervisor, PushReplyAwaiter,
85};
86use serde::{Deserialize, Serialize};
87
88use super::bridge::OutboxDeliveryCallback;
89use super::envelope::{CompletionFences, CompletionToken};
90use super::registry::{ConnectedWorkerRegistry, WorkerDelivery, WorkerRegistration};
91use crate::error::ServerError;
92use crate::namespace::{CallerIdentity, NamespaceGuard};
93
94/// Upper bound on how long a server-initiated intervention push waits for the
95/// worker's correlated control-plane acknowledgement. Activity dispatch replies
96/// use an unbounded held wait because an activity may legitimately run a while.
97const PUSH_REPLY_TIMEOUT: Duration = Duration::from_secs(30);
98
99/// Re-arm cadence for the engine-seam bridge's UNBOUNDED reply wait
100/// ([`receive_bridge_reply`]). Each elapsed poll is a benign re-arm, never a
101/// failure: the bridge dispatch contract imposes no activity timeout of its own
102/// (agent-style activities legitimately run for over an hour), exactly like the
103/// gRPC bridge's unbounded `recv`. Worker loss still terminates the wait
104/// promptly โ the awaiter wakes with the typed Disconnected error the moment the
105/// connection closes.
106const BRIDGE_REPLY_POLL: Duration = Duration::from_secs(1);
107
108/// Wire request carrying one scheduled activity to a liminal worker.
109///
110/// Mirrors the dispatch half of the gRPC `ActivityTask`: the fields the worker
111/// needs to execute the activity and to correlate its result back to the exact
112/// execution (`workflow_id`, `ordinal`, `run_id`). `run_id` rides end-to-end so
113/// the existing continue-as-new run gates hold over the liminal wire (the design
114/// doc ยง3.3 requirement that `RunId` stays on the wire).
115#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
116pub struct DispatchRequest {
117 /// Activity type the worker must execute.
118 pub activity_type: String,
119 /// Workflow that scheduled this fan-out activity. Carried in its serde form
120 /// so no fragile id parsing happens on the wire.
121 pub workflow_id: WorkflowId,
122 /// Pinned ordinal of this activity within the workflow's fan-out range.
123 pub ordinal: u64,
124 /// Run that dispatched this ordinal, when known (continue-as-new safety).
125 pub run_id: Option<RunId>,
126 /// Opaque execution-generation proof echoed verbatim by the worker.
127 pub completion_token: String,
128 /// Stable external-effect key for this run and action site.
129 pub idempotency_key: String,
130 /// Opaque activity input bytes (JSON-tagged on the aion side), in the one
131 /// payload-bytes wire shape ([`aion_core::payload_bytes`]): a base64 string
132 /// or an array of byte integers decode; the encoder is the codec's.
133 #[serde(with = "aion_core::payload_bytes")]
134 pub input: Vec<u8>,
135 /// One-based delivery attempt, mirroring the gRPC `ActivityTask.attempt`.
136 /// The engine-seam bridge threads the real attempt so a retry executes with
137 /// attempt-aware handler semantics identical to the gRPC transport; the
138 /// outbox path stamps the row's stored zero-based attempt as one-based.
139 /// Serde-defaulted to `1` so a frame from a pre-attempt server (or an old
140 /// recorded frame) still decodes as a first delivery.
141 #[serde(default = "first_attempt")]
142 pub attempt: u32,
143 /// Engine-provided routing/metadata labels, mirroring the gRPC
144 /// `ActivityTask.labels`. Empty (the serde default) on the outbox path,
145 /// which has no label source.
146 #[serde(default)]
147 pub labels: std::collections::BTreeMap<String, String>,
148 /// The server's heartbeat window in milliseconds when this dispatch is
149 /// tracked by the server's per-task liveness tracker (the engine-seam
150 /// bridge path), or `0` when it is not (the outbox path, whose liveness
151 /// backstop is its own retry loop). A non-zero window tells the worker to
152 /// pump automatic liveness beats at a quarter-window cadence so the
153 /// server's heartbeat sweeper never expires a healthy long-running
154 /// activity โ the exact liminal mirror of the gRPC worker's automatic
155 /// liveness pump.
156 #[serde(default)]
157 pub heartbeat_window_ms: u64,
158}
159
160/// Serde default for [`DispatchRequest::attempt`]: a frame that predates the
161/// attempt field is a first delivery.
162const fn first_attempt() -> u32 {
163 1
164}
165
166impl SchemaValidate for DispatchRequest {
167 fn schema_metadata() -> SchemaMetadata {
168 SchemaMetadata::new(
169 "aion.outbox.dispatch.request",
170 "1",
171 br#"{"type":"object"}"#.as_slice(),
172 )
173 }
174}
175
176/// Wire response carrying one worker result back to the outbox.
177///
178/// Mirrors the completion half of the gRPC `ActivityResult`: the correlation ids
179/// plus either a success result or a failure reason. `LiminalCompletionSource`
180/// maps this onto the existing [`OutboxDeliveryCallback`].
181#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
182pub struct DispatchResponse {
183 /// Workflow the completion belongs to.
184 pub workflow_id: WorkflowId,
185 /// Pinned ordinal the completion correlates against.
186 pub ordinal: u64,
187 /// Run that issued the dispatch, echoed back for the run gate.
188 pub run_id: Option<RunId>,
189 /// Opaque execution-generation proof echoed from the request.
190 pub completion_token: String,
191 /// Worker outcome: `Ok(result)` or `Err(reason)`.
192 pub outcome: Result<String, String>,
193}
194
195impl SchemaValidate for DispatchResponse {
196 fn schema_metadata() -> SchemaMetadata {
197 SchemaMetadata::new(
198 "aion.outbox.dispatch.response",
199 "1",
200 br#"{"type":"object"}"#.as_slice(),
201 )
202 }
203}
204
205/// Wire request carrying one neutral mid-run intervention command to a liminal
206/// worker (NOI-6, ยง6.2).
207///
208/// Rides the SAME liminal server-push channel as [`DispatchRequest`], distinguished
209/// on the wire by its unique required `intervention` field โ a plain
210/// [`DispatchRequest`] has no such field, so the worker demuxes the two by which
211/// one deserializes. The whole envelope is neutral: it carries an
212/// [`InterventionCommand`], never a harness type. Field-for-field mirrored by the
213/// worker's `liminal::InterventionRequest`.
214#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
215pub struct InterventionRequest {
216 /// The neutral command to route to the worker owning the target attempt.
217 pub intervention: aion_core::InterventionCommand,
218}
219
220impl SchemaValidate for InterventionRequest {
221 fn schema_metadata() -> SchemaMetadata {
222 SchemaMetadata::new(
223 "aion.intervention.request",
224 "1",
225 br#"{"type":"object"}"#.as_slice(),
226 )
227 }
228}
229
230/// Wire response carrying the worker's neutral intervention ack back to the server
231/// (NOI-6). Field-for-field mirrored by the worker's `liminal::InterventionReply`.
232#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
233pub struct InterventionReply {
234 /// The neutral applied/gated/stale outcome the operator receives.
235 pub outcome: aion_core::InterventionOutcome,
236}
237
238impl SchemaValidate for InterventionReply {
239 fn schema_metadata() -> SchemaMetadata {
240 SchemaMetadata::new(
241 "aion.intervention.reply",
242 "1",
243 br#"{"type":"object"}"#.as_slice(),
244 )
245 }
246}
247
248/// Reserved liminal channel a worker publishes automatic liveness beats on.
249///
250/// The liminal wire has no gRPC-style heartbeat frame, so per-task liveness
251/// rides a reserved publish channel exactly as the observability transcript
252/// does: the worker's runtime pumps a [`WorkerLivenessBeat`] per in-flight
253/// tracked dispatch at a quarter-window cadence, and the server's
254/// [`LiminalConnectionNotifier`] consumes the channel and refreshes the shared
255/// [`HeartbeatTracker`] โ so the #176 expiry sweeper is genuinely
256/// transport-agnostic: a healthy liminal worker running a long activity is
257/// never falsely expired, and a wedged one (which stops pumping) still is.
258/// Mirrored byte-for-byte by the worker crate's constant of the same name.
259pub const WORKER_LIVENESS_CHANNEL: &str = "aion.worker.liveness";
260
261/// Reserved liminal channel a worker announces its capacity and its
262/// intervention capabilities on.
263///
264/// The in-band [`WireWorkerRegistration`] frame is a published liminal protocol
265/// type and cannot carry aion-level metadata, so EVERY liminal worker publishes
266/// a [`WorkerCapabilitiesAnnouncement`] here immediately after registering (once
267/// per connection, so a redialed worker re-announces). The notifier consumes the
268/// channel and applies the announcement to the registered handle: the
269/// `max_concurrency` selection gates dispatch on, and the capability set the
270/// intervention router gates on and the ops console's live-attempts enumeration
271/// reports. Mirrored byte-for-byte by the worker crate's constant of the same
272/// name.
273///
274/// It is unconditional now, where it used to be sent only by a worker whose
275/// harness supported interventions. A worker that stays silent here never
276/// becomes dispatchable, so "nothing to announce" is no longer a reason not to
277/// announce.
278pub const WORKER_CAPABILITIES_CHANNEL: &str = "aion.worker.capabilities";
279
280/// Wire announcement of a worker's advertised intervention capabilities.
281///
282/// Field-for-field mirror of the worker crate's `WorkerCapabilitiesAnnouncement`
283/// (the same cross-crate contract the dispatch/response pairs pin). The worker
284/// is identified by its CONNECTION (the publish's pid resolves the registered
285/// worker), never by wire-supplied identity, so an announcement can only ever
286/// apply to the worker that sent it.
287#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
288pub struct WorkerCapabilitiesAnnouncement {
289 /// The neutral intervention primitives the worker's harness supports.
290 pub capabilities: aion_core::InterventionCapabilities,
291 /// How many activities the worker runs AT ONCE, as CONFIGURED on the worker
292 /// โ the liminal counterpart of the gRPC `RegisterWorker.max_concurrency`
293 /// field.
294 ///
295 /// It rides this announcement rather than the registration because
296 /// `liminal::protocol::WorkerRegistration` is a published wire type with no
297 /// capacity field. Until it arrives the server holds the worker at capacity
298 /// UNKNOWN and does not dispatch to it, so this is a required field of the
299 /// announcement and not an optional embellishment: a worker that omits it
300 /// never becomes selectable.
301 pub max_concurrency: u32,
302}
303
304/// Tolerant decode of a [`WorkerCapabilitiesAnnouncement`], used ONLY by the tap.
305///
306/// Identical to the announcement except that `max_concurrency` is optional here.
307/// The wire type keeps it required โ a worker that omits it is not sending a
308/// valid announcement โ but the SERVER must be able to tell "this payload is not
309/// an announcement at all" from "this is an announcement from a worker that
310/// predates the capacity field", because the two have completely different
311/// operator consequences and only one of them is a parse problem.
312///
313/// Absent means the worker stays at capacity unknown and is never selected,
314/// which the tap says out loud at ERROR with that consequence named.
315#[derive(Debug, Deserialize)]
316struct AnnouncementProbe {
317 /// The neutral intervention primitives the worker's harness supports.
318 capabilities: aion_core::InterventionCapabilities,
319 /// The worker's advertised capacity, absent on a pre-capacity build.
320 max_concurrency: Option<u32>,
321}
322
323/// Wire liveness beat for one in-flight dispatch (the liminal mirror of the
324/// gRPC `Heartbeat` frame, liveness-only โ progress payloads are not carried).
325///
326/// Field-for-field mirror of the worker crate's `WorkerLivenessBeat` (same
327/// serde field names + `aion-core` id types), the same cross-crate contract the
328/// dispatch/response pairs pin. The worker is identified by its CONNECTION (the
329/// publish's pid resolves the registered worker), never by wire-supplied
330/// identity, so a beat can only ever refresh tasks of the worker that sent it.
331#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
332pub struct WorkerLivenessBeat {
333 /// Workflow owning the in-flight activity being kept alive.
334 pub workflow_id: WorkflowId,
335 /// Pinned ordinal of the in-flight activity being kept alive.
336 pub ordinal: u64,
337}
338
339/// The single reserved character that separates channel segments. Because
340/// `namespace`/`task_queue` are free-form, any occurrence of this byte INSIDE a
341/// segment must be escaped so it cannot be mistaken for the segment boundary.
342const SEGMENT_SEPARATOR: char = '.';
343
344/// The escape character used by [`encode_segment`]. It must itself be escaped so
345/// the encoding stays injective (otherwise `%2E` as a literal field value would
346/// collide with an encoded `.`).
347const SEGMENT_ESCAPE: char = '%';
348
349/// Percent-encodes the two reserved characters (`.` and `%`) inside one channel
350/// segment so distinct segment values can never collide across the join.
351///
352/// This is a minimal, deterministic, per-segment escape: a literal `.` becomes
353/// `%2E` and a literal `%` becomes `%25`; every other byte (including the empty
354/// string) passes through unchanged. Because both the separator AND the escape
355/// char are encoded, the mapping `value -> encoded` is injective: it is exactly
356/// reversible by replacing `%2E -> .` and `%25 -> %`, so two distinct values
357/// can never encode to the same string. Dot-free, percent-free inputs (the
358/// normal case, e.g. `"remote"`, `"gpu"`) are returned byte-for-byte unchanged,
359/// so existing channels are stable.
360fn encode_segment(segment: &str) -> String {
361 // Fast path: nothing reserved, return an owned copy unchanged.
362 if !segment.contains([SEGMENT_SEPARATOR, SEGMENT_ESCAPE]) {
363 return segment.to_owned();
364 }
365 let mut encoded = String::with_capacity(segment.len());
366 for ch in segment.chars() {
367 match ch {
368 // Encode the escape char FIRST so an already-present `%` cannot be
369 // confused with one we introduce for the separator.
370 SEGMENT_ESCAPE => encoded.push_str("%25"),
371 SEGMENT_SEPARATOR => encoded.push_str("%2E"),
372 other => encoded.push(other),
373 }
374 }
375 encoded
376}
377
378/// Derives the liminal dispatch channel for a worker pool addressed
379/// `(namespace, task_queue)`, optionally pinned to a specific `node`.
380///
381/// This is the **single, total source of truth** for the channel string: every
382/// site that needs the channel a `(namespace, task_queue[, node])` pool
383/// dispatches to โ both this dispatcher and any future worker-pool subscription
384/// side โ MUST call this function so the two sides cannot drift. The format is
385/// `"aion.dispatch.{namespace}.{task_queue}"` for an unpinned dispatch and
386/// `"aion.dispatch.{namespace}.{task_queue}.{node}"` when a `node` is pinned;
387/// each `{segment}` is independently passed through [`encode_segment`].
388///
389/// # The subscriber contract (the seam this function pins, NODE-5 / 13-x)
390///
391/// The subscriber side remains the documented seam (it does not exist yet; 13-0
392/// uses liminal's in-server echo responder). The contract both sides MUST honour:
393///
394/// - An **unpinned** worker pool addressed `(namespace, task_queue)` subscribes
395/// to `dispatch_channel_name(namespace, task_queue, None)` and receives every
396/// unpinned dispatch for that pool.
397/// - A **node-pinned** dispatch (the row carries `Some(node)`) goes to
398/// `dispatch_channel_name(namespace, task_queue, Some(node))`, a DISTINCT
399/// channel. A worker running on that node which is meant to serve pinned work
400/// for the pool MUST ALSO subscribe to that node-specific channel โ the
401/// `None` channel alone will never deliver a node-pinned dispatch to it.
402///
403/// Because the `None` channel and any `Some(node)` channel are distinct strings,
404/// a node-pinned dispatch never reaches an unpinned-only subscriber and vice
405/// versa; node isolation is therefore enforced by the channel string itself.
406///
407/// # Injectivity (why the per-segment encode matters)
408///
409/// `namespace`, `task_queue` and `node` are all free-form (the design forbids
410/// preset categories), so a raw `format!` would be NON-injective: a `.` inside
411/// any field bleeds across the separator and pools the design declares disjoint
412/// collide onto one channel โ e.g. `("a.b", "c", None)` and `("a", "b.c", None)`
413/// would both yield `aion.dispatch.a.b.c`, a cross-pool leak on the very
414/// isolation dimension this routing exists to keep separate. Encoding each
415/// segment independently (the separator `.` and the escape `%` are escaped within
416/// a segment) makes the map from `(namespace, task_queue, node)` to channel
417/// string injective: distinct triples always yield distinct channels, ACROSS
418/// segment counts too. The node segment is appended only for `Some(node)`, and
419/// because no encoded segment can contain a bare separator, a 2-segment channel
420/// (unpinned) can never be confused with a 3-segment channel (pinned) โ e.g.
421/// `("a", "b", Some("c"))` and `("a", "b.c", None)` stay distinct, as do
422/// `("a.b", "c", None)` and `("a", "b", Some("c"))`.
423///
424/// `activity_type` is deliberately NOT part of the channel: it is *what to run*,
425/// matched by the worker after delivery (it rides inside [`DispatchRequest`]),
426/// not *which pool* โ see `docs/NAMESPACE-TASKQUEUE-SPLIT-DESIGN.md` ยง4.2. The
427/// function is total (defined for every input) and stable (the same
428/// `(namespace, task_queue, node)` always yields the same channel).
429#[must_use]
430pub fn dispatch_channel_name(namespace: &str, task_queue: &str, node: Option<&str>) -> String {
431 let namespace = encode_segment(namespace);
432 let task_queue = encode_segment(task_queue);
433 match node {
434 Some(node) => {
435 let node = encode_segment(node);
436 format!("aion.dispatch.{namespace}.{task_queue}.{node}")
437 }
438 None => format!("aion.dispatch.{namespace}.{task_queue}"),
439 }
440}
441
442/// Derives the liminal dispatch channel for a claimed outbox row.
443///
444/// Thin wrapper over [`dispatch_channel_name`] reading the row's durable
445/// `(namespace, task_queue)` (NSTQ-2 columns) and its optional `node` (NODE-2):
446/// when `row.node` is `Some`, the row dispatches to the node-pinned sub-channel;
447/// when `None`, it derives the byte-identical unpinned channel. Kept
448/// free-standing so the dispatch path and tests derive the row's channel
449/// identically.
450#[must_use]
451pub fn channel_for_row(row: &OutboxRow) -> String {
452 dispatch_channel_name(&row.namespace, &row.task_queue, row.node.as_deref())
453}
454
455/// Wraps a reason in the existing worker-dispatch error so a non-deliverable
456/// dispatch drives the outbox's unchanged retry/backoff/dead-letter path. The
457/// row-derived `channel` is surfaced as the `activity_type` field for operator
458/// diagnostics (the field is a free-form context string on this transport's
459/// error).
460fn dispatch_error(channel: &str, reason: String) -> ServerError {
461 ServerError::WorkerDispatch {
462 namespace: "liminal".to_owned(),
463 activity_type: channel.to_owned(),
464 reason,
465 }
466}
467
468/// Receives one worker result over liminal and re-enters it into aion.
469///
470/// Holds the installed [`OutboxDeliveryCallback`] (the same prod
471/// `ServerOutboxDeliveryCallback` the gRPC completion path uses) and maps a
472/// [`DispatchResponse`] onto it, threading `run_id` so the continue-as-new run
473/// gates apply unchanged.
474pub struct LiminalCompletionSource {
475 callback: Arc<dyn OutboxDeliveryCallback>,
476 completion_fences: CompletionFences,
477}
478
479impl std::fmt::Debug for LiminalCompletionSource {
480 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
481 f.debug_struct("LiminalCompletionSource")
482 .finish_non_exhaustive()
483 }
484}
485
486impl LiminalCompletionSource {
487 /// Build a completion source over the shared outbox delivery callback.
488 #[must_use]
489 pub fn new(callback: Arc<dyn OutboxDeliveryCallback>) -> Self {
490 Self {
491 callback,
492 completion_fences: CompletionFences::default(),
493 }
494 }
495
496 /// Share the authoritative generation registry used by dispatch.
497 #[must_use]
498 pub fn with_completion_fences(mut self, completion_fences: CompletionFences) -> Self {
499 self.completion_fences = completion_fences;
500 self
501 }
502
503 /// Re-enter one worker result into aion through the delivery callback.
504 ///
505 /// Returns the callback's `bool`: `true` when delivered to a live run,
506 /// `false` when no run is live (the expected stale-completion drop that
507 /// recovery re-arms). A success outcome routes to `deliver_completion`; a
508 /// failure outcome to `deliver_failure`.
509 ///
510 /// # Errors
511 ///
512 /// Returns [`ServerError`] when the response carries an unparseable id or
513 /// the engine rejects the delivery.
514 pub fn deliver(&self, response: &DispatchResponse) -> Result<bool, ServerError> {
515 let run_id = response.run_id.as_ref().ok_or_else(|| {
516 dispatch_error(
517 "liminal completion",
518 "activity result run id is missing; refusing run-gate bypass".to_owned(),
519 )
520 })?;
521 let activity_id = ActivityId::from_sequence_position(response.ordinal);
522 let completion_token = CompletionToken::from_wire(
523 &response.workflow_id,
524 &activity_id,
525 response.completion_token.clone(),
526 )?;
527 match self
528 .completion_fences
529 .accept(&response.workflow_id, &activity_id, &completion_token)
530 {
531 Ok(accepted) => {
532 // The consumed generation is bound and deliberately NOT
533 // restored: unlike the bridge, this path has no settlement that
534 // can fail after acceptance โ the callback below IS the
535 // settlement and its failure is returned straight to the
536 // caller โ so there is no `restore_if_absent` site on this
537 // transport for the slip to feed.
538 tracing::debug!(
539 workflow_id = %response.workflow_id,
540 %activity_id,
541 attempt = accepted.attempt(),
542 "liminal completion consumed the execution generation"
543 );
544 }
545 Err(error) => {
546 tracing::warn!(
547 workflow_id = %response.workflow_id,
548 %activity_id,
549 %error,
550 "liminal completion fence rejected a late or stale reply"
551 );
552 return Err(error);
553 }
554 }
555 match &response.outcome {
556 Ok(result) => self.callback.deliver_completion(
557 &response.workflow_id,
558 &activity_id,
559 Some(run_id),
560 result.clone(),
561 ),
562 Err(reason) => self.callback.deliver_failure(
563 &response.workflow_id,
564 &activity_id,
565 Some(run_id),
566 reason.clone(),
567 ),
568 }
569 }
570}
571
572/// Rebuilds the activity input payload from the wire request.
573///
574/// The aion side tags activity input as JSON; the wire carries the raw bytes, so
575/// a worker (or the test responder standing in for one) reconstructs the typed
576/// [`Payload`] with the JSON content type.
577#[must_use]
578pub fn payload_from_request(request: &DispatchRequest) -> Payload {
579 Payload::new(ContentType::Json, request.input.clone())
580}
581
582/// Delivery handle for a liminal-connected worker held in the worker registry.
583///
584/// A worker that connects over liminal is a first-class registry member selected
585/// the SAME way as a gRPC worker (`select_worker` on `(namespace, task_queue,
586/// node)`); this is the delivery leg the registry holds for it. It pairs the
587/// [`ConnectionSupervisor`] that owns the worker's connection with that
588/// connection's beamr `pid`, so [`Self::push_dispatch`] can push a
589/// [`DispatchRequest`] out on the worker's existing socket (the LSUB-0
590/// server-push primitive) and [`receive_bridge_reply`] can block for the
591/// correlated [`DispatchResponse`].
592#[derive(Clone)]
593pub struct LiminalWorkerDelivery {
594 supervisor: ConnectionSupervisor,
595 pid: u64,
596}
597
598impl std::fmt::Debug for LiminalWorkerDelivery {
599 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
600 f.debug_struct("LiminalWorkerDelivery")
601 .field("pid", &self.pid)
602 .finish_non_exhaustive()
603 }
604}
605
606impl LiminalWorkerDelivery {
607 /// Build a delivery handle for the worker reachable on connection `pid`
608 /// through `supervisor`.
609 #[must_use]
610 pub const fn new(supervisor: ConnectionSupervisor, pid: u64) -> Self {
611 Self { supervisor, pid }
612 }
613
614 /// The connection pid this worker is addressed on.
615 #[must_use]
616 pub const fn pid(&self) -> u64 {
617 self.pid
618 }
619
620 /// Whether the supervisor still owns this worker's connection.
621 ///
622 /// The liminal answer to "is the push leg open", which the expiry sweep
623 /// needs as an explicit fact rather than inferring it from silence. It asks
624 /// the supervisor's own connection table โ the same table the push goes
625 /// through โ so a connection that has closed is structurally absent rather
626 /// than merely quiet. It says nothing about whether the worker ANSWERS on
627 /// that connection: that is the liveness probe's question, and the two are
628 /// deliberately separate facts.
629 #[must_use]
630 pub fn is_connected(&self) -> bool {
631 self.supervisor.is_tracked(self.pid)
632 }
633
634 /// Push one dispatch and wait for its reply without an activity-duration
635 /// bound, abandoning only when `keep_waiting` becomes false at a poll
636 /// boundary โ [`Self::push_dispatch`] then [`receive_bridge_reply`] in one
637 /// call.
638 ///
639 /// The production arms (the outbox delivery and the engine-seam bridge) do
640 /// NOT call this: they call the two halves themselves because the lease
641 /// accept hook ([`super::task_delivery::DeliveryAccepted`]) must run between
642 /// the push being acknowledged and the reply wait โ on liminal the reply IS
643 /// the completion, and a lease recorded after it would be a lease recorded
644 /// after the fact. This composition is for callers outside the crate that
645 /// stand in for a dispatcher (the e2e harnesses), where the receive half is
646 /// not reachable on its own.
647 ///
648 /// # Errors
649 ///
650 /// Returns the same typed push, receive, and decode failures as the shared
651 /// bridge wait.
652 pub fn dispatch_held(
653 &self,
654 request: &DispatchRequest,
655 keep_waiting: impl Fn() -> bool,
656 ) -> Result<Option<DispatchResponse>, ServerError> {
657 let awaiter = self.push_dispatch(request)?;
658 receive_bridge_reply(&awaiter, keep_waiting)
659 }
660
661 /// Serialize and push one dispatch out on the worker's connection, returning
662 /// the awaiter for its correlated reply โ the shared push half of both
663 /// unbounded waits: the outbox arm (`LiminalTaskDelivery`) and the
664 /// engine-seam bridge dispatcher both push here and wait with
665 /// [`receive_bridge_reply`], matching the gRPC bridge contract. One frame
666 /// format, one push primitive, one held wait policy. The two halves are
667 /// separate calls so the attempt's lease can be recorded between them,
668 /// at the instant the push is acknowledged (WA-010 R3).
669 ///
670 /// DELIBERATELY NO REPLY DEADLINE, unlike the liveness and intervention
671 /// pushes. A dispatch's reply IS the activity's completion, so its lifetime
672 /// is the activity's โ `remote_gates.awl` declares legs at `timeout 45m`.
673 /// [`receive_bridge_reply`] re-arms on every poll timeout and waits
674 /// unbounded by contract, so this push NEVER abandons its slot and cannot
675 /// leak one: the slot is held for exactly as long as work is outstanding
676 /// and resolves when the reply lands. Attaching a deadline here would kill
677 /// long activities, and would be a regression, not a hardening. The leak
678 /// fixed on the other two sites came from ABANDONING a no-deadline slot,
679 /// which this site never does.
680 ///
681 /// # Errors
682 ///
683 /// Returns [`ServerError::WorkerDispatchUnservable`] when liminal proves the
684 /// frame exceeds the connection's whole outbound buffer (never retried),
685 /// [`ServerError::WorkerBusy`] when liminal's typed connection cap refuses
686 /// admission, [`ServerError::WorkerConnectionLost`] for every other
687 /// push-enqueue failure, and [`ServerError::WorkerDispatch`] when the request
688 /// cannot be serialized.
689 pub(crate) fn push_dispatch(
690 &self,
691 request: &DispatchRequest,
692 ) -> Result<PushReplyAwaiter, ServerError> {
693 let payload = serde_json::to_vec(request).map_err(|error| {
694 dispatch_error("liminal-push", format!("request serialize failed: {error}"))
695 })?;
696 self.supervisor
697 .push_to_connection(self.pid, payload)
698 .map_err(|error| classify_push_error(&error))
699 }
700
701 /// Push already-encoded `payload` out on the worker's connection with an
702 /// explicit reply `deadline`, returning the awaiter for its correlated
703 /// reply.
704 ///
705 /// The raw push primitive behind the typed pushes above, used by the
706 /// connection liveness probe
707 /// ([`LivenessProbe`](super::liminal_liveness::LivenessProbe)) so the
708 /// dead-man switch rides the SAME server-push leg a dispatch does โ a ping
709 /// that reaches the worker proves the exact path a dispatch would take, not
710 /// a parallel one that could be healthy while the real one is not.
711 ///
712 /// The deadline is LOAD-BEARING and is why this is not
713 /// `push_to_connection`. Riding the dispatch leg means sharing its bounded
714 /// ยง5 `max_pending_pushes_per_connection` admission, and a no-deadline slot
715 /// is reclaimed only by a consumed reply or a connection close โ never by
716 /// the caller giving up. A probe that abandons an unanswered ping every
717 /// cadence therefore LEAKS one slot per round until the cap is exhausted,
718 /// at which point no dispatch, intervention or ping can be pushed on that
719 /// connection again. Measured live on run `dfd2117c`: 32 abandoned pings,
720 /// then permanent refusal, โ240 s from first blocked ping. The probe built
721 /// to prove the dispatch path works is what destroyed it. With a deadline,
722 /// expiry removes the slot and RELEASES its cap admission.
723 ///
724 /// # Errors
725 ///
726 /// Returns [`ServerError::WorkerDispatchUnservable`] when liminal proves the
727 /// frame exceeds the connection's whole outbound buffer,
728 /// [`ServerError::WorkerBusy`] when liminal's typed connection cap refuses
729 /// admission, and [`ServerError::WorkerConnectionLost`] for every other
730 /// push-enqueue failure.
731 pub fn push_payload_with_deadline(
732 &self,
733 payload: Vec<u8>,
734 deadline: Duration,
735 ) -> Result<PushReplyAwaiter, ServerError> {
736 self.supervisor
737 .push_to_connection_with_deadline(self.pid, payload, deadline)
738 .map_err(|error| classify_push_error(&error))
739 }
740
741 /// Push one neutral intervention command out on the worker's connection and
742 /// block for its correlated ack reply (NOI-6, ยง6.2).
743 ///
744 /// Uses the same connection as activity dispatch but carries an
745 /// [`InterventionRequest`] and decodes an [`InterventionReply`], so it rides the SAME server-push
746 /// channel as an activity dispatch. The push is a blocking, thread-based liminal
747 /// call; the async router runs it off the runtime.
748 ///
749 /// # Errors
750 ///
751 /// Returns [`ServerError::WorkerConnectionLost`] when the worker connection was
752 /// gone at push time or closed before replying (so the router surfaces the
753 /// too-late no-op); returns [`ServerError::WorkerDispatch`] when the request
754 /// cannot be serialized, the reply times out, or the reply cannot be decoded.
755 pub fn push_intervention(
756 &self,
757 request: &InterventionRequest,
758 ) -> Result<InterventionReply, ServerError> {
759 let payload = serde_json::to_vec(request).map_err(|error| {
760 dispatch_error(
761 "liminal-push",
762 format!("intervention serialize failed: {error}"),
763 )
764 })?;
765 // Deadline-bounded, and it must be: this wait ABANDONS on timeout (the
766 // `receive` error below is terminal, not a re-arm), so a no-deadline
767 // slot would be held until the connection closed and would count
768 // against the connection's ยง5 pending-push cap forever. The deadline
769 // matches the wait, so an intervention that goes unanswered releases
770 // its admission instead of leaking it. Contrast `push_dispatch`, whose
771 // wait re-arms and is unbounded by contract, and which therefore
772 // correctly takes no deadline.
773 let awaiter = self
774 .supervisor
775 .push_to_connection_with_deadline(self.pid, payload, PUSH_REPLY_TIMEOUT)
776 .map_err(|error| {
777 ServerError::worker_connection_lost(
778 "liminal-push",
779 format!("push intervention to worker failed: {error}"),
780 )
781 })?;
782 let reply = awaiter.receive(PUSH_REPLY_TIMEOUT).map_err(|error| {
783 if is_connection_closed_reply_error(&error) {
784 ServerError::worker_connection_lost(
785 "liminal-push",
786 format!("worker connection closed before intervention ack: {error}"),
787 )
788 } else {
789 dispatch_error("liminal-push", format!("intervention ack failed: {error}"))
790 }
791 })?;
792 serde_json::from_slice(&reply).map_err(|error| {
793 dispatch_error(
794 "liminal-push",
795 format!("intervention ack decode failed: {error}"),
796 )
797 })
798 }
799}
800
801/// Decodes one correlated reply payload as a [`DispatchResponse`].
802///
803/// Shared by the outbox arm and the bridge โ both wait through
804/// [`receive_bridge_reply`] โ so the two paths can never diverge on the wire's
805/// reply shape.
806fn decode_dispatch_response(reply: &[u8]) -> Result<DispatchResponse, ServerError> {
807 serde_json::from_slice(reply).map_err(|error| {
808 dispatch_error(
809 "liminal-push",
810 format!("worker reply decode failed: {error}"),
811 )
812 })
813}
814
815/// Blocks for the correlated reply to an engine-seam BRIDGE dispatch push, with
816/// the bridge's UNBOUNDED wait contract: the engine imposes no activity timeout
817/// of its own, so an elapsed [`BRIDGE_REPLY_POLL`] merely re-arms the wait โ the
818/// exact liminal mirror of the gRPC bridge's unbounded `recv`, which is released
819/// only by a completion or by stream teardown. The wait terminates on exactly:
820///
821/// - **the reply** โ decoded as the worker's [`DispatchResponse`] and returned
822/// as `Ok(Some(response))`. A reply already buffered when a poll fires always
823/// wins over abandonment: the awaiter is drained before `keep_waiting` runs;
824/// - **abandonment** โ `keep_waiting()` returned `false` at a poll boundary
825/// (the bridge passes "is this dispatch still tracked in-flight?"): the
826/// dispatch was resolved by another path (expiry sweep, shutdown drain, or a
827/// cleanup), so the wait returns `Ok(None)` and the router thread exits
828/// instead of parking on the connection indefinitely;
829/// - **an oversize frame** โ liminal's connection process proved the encoded
830/// dispatch larger than the connection's whole outbound buffer and failed the
831/// reply slot BY NAME. The refusal arrives HERE, on the awaiter, not on the
832/// push call: `push_to_connection` only reserves the slot, the enqueue that
833/// measures the frame runs afterwards in the connection process. It is
834/// surfaced as [`ServerError::WorkerDispatchUnservable`], the class both
835/// dispatch arms settle terminally on first observation, so the outbox
836/// dead-letters the row and the bridge reports `terminal:` instead of
837/// retrying a certainty;
838/// - **worker loss** โ the connection closed before replying: the awaiter wakes
839/// PROMPTLY with liminal's typed Disconnected error, surfaced as
840/// [`ServerError::WorkerConnectionLost`] so the bridge reports the same
841/// TRANSPORT-loss class the gRPC teardown sweep does;
842/// - **an unrecognized receive fault or a decode failure** โ surfaced as
843/// [`ServerError::WorkerDispatch`].
844///
845/// Runs on a dedicated bridge reply thread, never on an async runtime worker.
846///
847/// # Errors
848///
849/// Returns [`ServerError::WorkerDispatchUnservable`] when liminal proves the
850/// frame exceeds the connection's whole outbound buffer,
851/// [`ServerError::WorkerConnectionLost`] on worker loss and
852/// [`ServerError::WorkerDispatch`] on a receive fault or reply decode failure.
853pub(crate) fn receive_bridge_reply(
854 awaiter: &PushReplyAwaiter,
855 keep_waiting: impl Fn() -> bool,
856) -> Result<Option<DispatchResponse>, ServerError> {
857 loop {
858 match awaiter.receive(BRIDGE_REPLY_POLL) {
859 Ok(reply) => return decode_dispatch_response(&reply).map(Some),
860 // A bare poll timeout re-arms the wait while the dispatch is still
861 // outstanding (the wait is unbounded by contract; see the bridge
862 // module docs), and ends it cleanly once the dispatch was resolved
863 // elsewhere โ the poll cadence bounds the router thread's lifetime
864 // to one poll past that resolution.
865 Err(LiminalServerError::PushReplyTimeout { .. }) => {
866 if !keep_waiting() {
867 return Ok(None);
868 }
869 }
870 Err(error) => return Err(classify_reply_error(&error)),
871 }
872 }
873}
874
875/// Classify a typed liminal push-REPLY failure without relying on error text โ
876/// the receive-side twin of [`classify_push_error`].
877///
878/// The oversize-frame refusal is checked FIRST and by name, exactly as on the
879/// push side, because this is the arm it actually arrives on: liminal settles
880/// that slot from the connection process after the push call has returned. A
881/// generic wrap here read as the retryable dispatch class, and that is how a
882/// frame that could never fit was re-pushed for every attempt in the budget
883/// while the operator's refusal said "retryable".
884fn classify_reply_error(error: &LiminalServerError) -> ServerError {
885 if let Some(unservable) = unservable_frame_refusal(error) {
886 return unservable;
887 }
888 if is_connection_closed_reply_error(error) {
889 return ServerError::worker_connection_lost(
890 "liminal-push",
891 format!("worker connection closed before reply: {error}"),
892 );
893 }
894 dispatch_error("liminal-push", format!("worker reply failed: {error}"))
895}
896
897/// Returns true when a liminal push-reply error is the *Disconnected* case (the
898/// worker's connection closed before it replied), as opposed to a genuine reply
899/// timeout (the worker is alive but slow).
900///
901/// Liminal returns these as distinct TYPED variants โ
902/// `ServerError::PushReplyDisconnected` vs `PushReplyTimeout` (see `liminal-server`
903/// `supervisor.rs` `PushReplyAwaiter::receive`) โ so this is a type match, not a
904/// message-text match: a worker that DIED (connection-lost, fast failover) is told
905/// apart from one that is merely SLOW (genuine timeout, normal backoff) by variant.
906fn is_connection_closed_reply_error(error: &LiminalServerError) -> bool {
907 matches!(error, LiminalServerError::PushReplyDisconnected { .. })
908}
909
910/// The one refusal both halves of a push must recognise, by variant: liminal
911/// proved the encoded frame larger than the connection's whole outbound buffer.
912///
913/// A frame that cannot fit the whole buffer can never be carried, so it must
914/// never fall into a lost-connection or generic dispatch arm โ both read as
915/// "fail over and retry", and retrying a certainty is exactly what wrote 846
916/// leases for one activity. The detail names the two numbers and the key an
917/// operator would change. `None` for every other error, which each caller
918/// then classifies for its own side of the push.
919fn unservable_frame_refusal(error: &LiminalServerError) -> Option<ServerError> {
920 let LiminalServerError::PushFrameExceedsOutboundCapacity {
921 needed, capacity, ..
922 } = error
923 else {
924 return None;
925 };
926 Some(ServerError::worker_dispatch_unservable(
927 "liminal-push",
928 format!(
929 "the dispatch frame is {needed} bytes and this worker connection's whole outbound \
930 buffer is {capacity} bytes (`outbox.{OUTBOUND_BOUND_KEY}`); no retry can carry \
931 it: {error}"
932 ),
933 ))
934}
935
936/// Classify a typed liminal push refusal without relying on error text.
937fn classify_push_error(error: &LiminalServerError) -> ServerError {
938 if let Some(unservable) = unservable_frame_refusal(error) {
939 return unservable;
940 }
941 if is_connection_cap_error(error) {
942 ServerError::worker_busy(
943 "liminal-push",
944 format!("worker connection push cap reached: {error}"),
945 )
946 } else {
947 ServerError::worker_connection_lost(
948 "liminal-push",
949 format!("push to worker failed: {error}"),
950 )
951 }
952}
953
954/// The `[outbox]` key that sets each worker connection's outbound bound. Named
955/// in the unservable refusal so the operator reading it knows what to change;
956/// spelled once here and once on the config field, and a test pins them equal.
957pub(crate) const OUTBOUND_BOUND_KEY: &str = "liminal_max_connection_outbound_bytes";
958
959/// Liminal's typed admission refusal when a connection has too many held pushes.
960fn is_connection_cap_error(error: &LiminalServerError) -> bool {
961 matches!(error, LiminalServerError::ConnectionCapReached { .. })
962}
963
964/// RAII guard that releases an [`AttemptOwnerIndex`](super::intervention::AttemptOwnerIndex)
965/// binding when the dispatch resolves โ on the reply, an error, or a panic โ so
966/// the back-index tracks exactly the attempts currently in flight (NOI-6).
967///
968/// Shared by both liminal dispatch arms: the outbox row wait holds it across
969/// its blocking `dispatch` call, and the engine-seam bridge hands it to the
970/// dispatch's reply-router thread, which drops it on every exit path.
971pub(crate) struct AttemptOwnerGuard {
972 owners: super::intervention::AttemptOwnerIndex,
973 key: super::intervention::AttemptKey,
974}
975
976impl AttemptOwnerGuard {
977 /// Bind `key` to `worker` in `owners` and return the guard that releases
978 /// the binding on drop.
979 pub(crate) fn bind(
980 owners: super::intervention::AttemptOwnerIndex,
981 key: super::intervention::AttemptKey,
982 worker: super::registry::WorkerId,
983 ) -> Self {
984 owners.bind(key.clone(), worker);
985 Self { owners, key }
986 }
987}
988
989impl Drop for AttemptOwnerGuard {
990 fn drop(&mut self) {
991 self.owners.release(&self.key);
992 }
993}
994
995/// Normalize a wire `node` (`Option<String>`) onto the registry's optional
996/// locality affinity, applying the SAME none-convention the gRPC registration
997/// path uses (`registry::optional_node`): an empty string carries no node, so it
998/// collapses to `None`; any non-empty value is the worker's advertised node.
999///
1000/// The wire already models `node` as `Option<String>`, but a worker that joins
1001/// `Some("")` (the empty-string node) must not register a distinct empty-node
1002/// affinity that no pinned dispatch could ever match โ it is semantically
1003/// unpinned, exactly as the gRPC proto3 empty default is. Folding it to `None`
1004/// here keeps the two registration paths byte-for-byte equivalent.
1005fn normalize_wire_node(node: Option<&str>) -> Option<String> {
1006 node.filter(|value| !value.is_empty())
1007 .map(ToOwned::to_owned)
1008}
1009
1010/// Connection-keyed [`ConnectionNotifier`] that turns liminal's in-band worker
1011/// registration into a first-class [`ConnectedWorkerRegistry`] membership.
1012///
1013/// This is the SERVER half of LSUB-L2: when a worker connects with a
1014/// [`WireWorkerRegistration`] (the SDK's `connect_with_registration`), liminal's
1015/// connection process invokes [`on_worker_registered`](Self::on_worker_registered)
1016/// with the connection's beamr `pid` and the worker's declared
1017/// `(namespaces, task_queue, node, activity_types)`. The notifier builds a
1018/// [`WorkerDelivery::Liminal`] over the connection and inserts it into the
1019/// registry โ the SAME registry entry, selected the SAME way, as a gRPC worker โ
1020/// retiring the LSUB-1 out-of-band `active_connection_pids()` + hard-coded
1021/// registration hack.
1022///
1023/// # Lifetime of the registration guard
1024///
1025/// [`ConnectedWorkerRegistry::register_delivery`] returns a
1026/// [`WorkerRegistration`] guard whose drop deregisters the worker. The notifier
1027/// OWNS that guard keyed by `pid` (`Mutex<HashMap<u64, WorkerRegistration>>`), so
1028/// the registration lives exactly as long as the connection: it is inserted on
1029/// register and removed (dropped) on
1030/// [`on_worker_unregistered`](Self::on_worker_unregistered), which liminal fires
1031/// on connection close.
1032///
1033/// # Construction-order cycle (notifier <-> supervisor)
1034///
1035/// [`LiminalWorkerDelivery`] needs a [`ConnectionSupervisor`] handle to push to
1036/// the worker's connection, but the supervisor is itself constructed WITH this
1037/// notifier ([`ConnectionSupervisor::with_services_and_notifier`]) โ a cycle. The
1038/// notifier therefore holds the supervisor behind a [`OnceLock`], populated
1039/// IMMEDIATELY after the supervisor is built via [`Self::bind_supervisor`]. The
1040/// `OnceLock` is never read before it is set in correct wiring (a worker can only
1041/// register after the listener โ built after the supervisor and after
1042/// `bind_supervisor` โ accepts its connection); if it somehow were, registration
1043/// is REJECTED with a typed error rather than panicking, so there is no
1044/// production `unwrap`/`expect` and no second always-`None` code path.
1045pub struct LiminalConnectionNotifier {
1046 registry: ConnectedWorkerRegistry,
1047 contract_catalog: Option<Arc<aion::Engine>>,
1048 supervisor: OnceLock<ConnectionSupervisor>,
1049 guards: Mutex<HashMap<u64, WorkerRegistration>>,
1050 /// The neutral intervention primitives a liminal-connected agent worker
1051 /// advertises (NOI-6, item 4). The liminal `WorkerRegistration` wire has a fixed
1052 /// shape that cannot carry this, so it is configured on the notifier at the
1053 /// composition root from the harness's advertised `AgentSession::capabilities()`
1054 /// and recorded on every registered worker's handle, where the intervention
1055 /// router gates on it. Default empty = observability-only (a plain activity
1056 /// worker), so the router offers no controls for it.
1057 intervention_capabilities: aion_core::InterventionCapabilities,
1058 /// The transcript sequencer a worker's observability publishes drain into
1059 /// (NOI-5b), plus the Tokio [`Handle`](tokio::runtime::Handle) to bridge the
1060 /// synchronous connection-process callback onto the async publish. `None` (the
1061 /// default, and every non-agent boot) makes the observability tap a no-op, so a
1062 /// worker publish to the reserved channel is simply ignored by the notifier.
1063 transcript: Option<TranscriptTap>,
1064 /// The SAME per-task liveness tracker the bridge dispatcher tracks into and
1065 /// the #176 expiry sweeper expires from. A worker's automatic
1066 /// [`WorkerLivenessBeat`] publishes on [`WORKER_LIVENESS_CHANNEL`] refresh
1067 /// their task stamps here, keeping the sweeper honest for liminal-delivered
1068 /// dispatches. `None` (a wiring that never bridges dispatches, e.g. isolated
1069 /// tests) consumes and drops the beats.
1070 heartbeat_tracker: Option<super::heartbeat::HeartbeatTracker>,
1071 /// The namespace admission every registration passes through, bound by
1072 /// the commissioning path. `None` FAILS CLOSED: a notifier nobody bound an
1073 /// admission to refuses every registration by name rather than admitting
1074 /// unguarded, which is exactly the defect this field closes.
1075 admission: Option<RegistrationAdmission>,
1076}
1077
1078/// The observability-drain leg of the notifier: a bounded, ORDERED queue into
1079/// the one drain task that publishes transcript events sequentially.
1080///
1081/// One consumer is load-bearing, not an implementation detail: a spawned task
1082/// per event (the pre-2026-07-23 shape) made every in-flight frame a
1083/// concurrent writer racing the same stream head, turning the sequencer's
1084/// optimistic-append loop into an O(Nยฒ) conflict stampede under load โ and
1085/// destroyed `worker_seq` order on the durable transcript. Sequential
1086/// draining preserves arrival order and leaves the conflict-retry loop to
1087/// handle only genuine cross-process races (failover adoption).
1088#[derive(Clone)]
1089struct TranscriptTap {
1090 queue: tokio::sync::mpsc::Sender<aion_core::ActivityEvent>,
1091}
1092
1093/// Bound on the transcript drain queue: events a slow durable append cannot
1094/// keep up with are dropped (with a warning) rather than buffered without
1095/// limit. Live streaming is best-effort; the bound protects server memory.
1096const TRANSCRIPT_QUEUE_CAPACITY: usize = 4096;
1097
1098impl std::fmt::Debug for LiminalConnectionNotifier {
1099 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1100 f.debug_struct("LiminalConnectionNotifier")
1101 .field("supervisor_bound", &self.supervisor.get().is_some())
1102 .finish_non_exhaustive()
1103 }
1104}
1105
1106impl LiminalConnectionNotifier {
1107 /// Build a notifier that registers connecting workers into `registry`.
1108 ///
1109 /// The supervisor handle is bound separately via [`Self::bind_supervisor`]
1110 /// immediately after the supervisor is constructed, resolving the
1111 /// notifier <-> supervisor construction cycle (see the type docs).
1112 #[must_use]
1113 pub fn new(registry: ConnectedWorkerRegistry) -> Self {
1114 Self {
1115 registry,
1116 contract_catalog: None,
1117 supervisor: OnceLock::new(),
1118 guards: Mutex::new(HashMap::new()),
1119 intervention_capabilities: aion_core::InterventionCapabilities::none(),
1120 transcript: None,
1121 heartbeat_tracker: None,
1122 admission: None,
1123 }
1124 }
1125
1126 /// Bind the namespace admission a liminal registration is judged by โ
1127 /// the same guard, mint-or-gate and placement admission the gRPC path
1128 /// applies โ and the Tokio runtime `handle` the synchronous
1129 /// connection-process callback bridges onto to run it.
1130 ///
1131 /// The handle is named, never captured from ambient context: the e2e
1132 /// harnesses build this notifier in a sync `start()` BEFORE they build
1133 /// their runtime, and a capture there was `None`, refusing every
1134 /// registration on the very tests meant to exercise it while the
1135 /// production path (built inside `run_server`) happened to work. A
1136 /// listener commissioned outside any runtime has no handle to give and
1137 /// refuses to be commissioned, at the call site, by name.
1138 ///
1139 /// `auth_enabled` mirrors `auth.enabled`: the liminal protocol's
1140 /// registration frame carries an `identity` string and no credential, so
1141 /// on a server that authenticates callers a liminal registration cannot
1142 /// be scoped and is refused by name; on an auth-off server it is admitted
1143 /// as the operator identity, exactly as a gRPC registration with no
1144 /// metadata is.
1145 #[must_use]
1146 pub fn with_admission(
1147 mut self,
1148 guard: NamespaceGuard,
1149 auth_enabled: bool,
1150 handle: tokio::runtime::Handle,
1151 ) -> Self {
1152 self.admission = Some(RegistrationAdmission {
1153 guard,
1154 auth_enabled,
1155 handle,
1156 });
1157 self
1158 }
1159
1160 /// Install the live package catalog used to refuse incompatible workers
1161 /// before liminal acknowledges or publishes their registration.
1162 #[must_use]
1163 pub fn with_contract_catalog(mut self, engine: Arc<aion::Engine>) -> Self {
1164 self.contract_catalog = Some(engine);
1165 self
1166 }
1167
1168 /// Install the shared per-task liveness tracker so a worker's automatic
1169 /// [`WorkerLivenessBeat`] publishes refresh the SAME in-flight entries the
1170 /// bridge dispatcher tracks and the #176 sweeper expires.
1171 ///
1172 /// MUST be wired on every boot that hosts the engine-seam bridge (the
1173 /// production composition does), or the sweeper would expire healthy
1174 /// liminal workers running activities longer than the heartbeat window.
1175 /// Without it (isolated tests that never bridge-dispatch) beats are
1176 /// consumed and dropped.
1177 #[must_use]
1178 pub fn with_heartbeat_tracker(mut self, tracker: super::heartbeat::HeartbeatTracker) -> Self {
1179 self.heartbeat_tracker = Some(tracker);
1180 self
1181 }
1182
1183 /// Install the transcript sequencer a worker's observability publishes drain into
1184 /// (NOI-5b), capturing the CURRENT Tokio runtime handle to bridge the synchronous
1185 /// connection-process callback onto the async publish.
1186 ///
1187 /// MUST be called from within a Tokio runtime (the server boot path is), so the
1188 /// captured [`Handle`](tokio::runtime::Handle) can spawn the append+fan-out when a
1189 /// worker publishes a transcript event over the reserved channel. Without this
1190 /// builder the observability tap is a no-op (a plain, non-agent deployment).
1191 ///
1192 /// # Panics
1193 ///
1194 /// Panics if called outside a Tokio runtime โ a construction-time wiring error in
1195 /// the server boot, never a runtime condition (the boot path always builds the
1196 /// notifier inside the server runtime).
1197 #[must_use]
1198 pub fn with_transcript_publisher(
1199 mut self,
1200 publisher: crate::activity_publisher::ActivityEventPublisher,
1201 ) -> Self {
1202 let (queue, mut events) =
1203 tokio::sync::mpsc::channel::<aion_core::ActivityEvent>(TRANSCRIPT_QUEUE_CAPACITY);
1204 // The ONE drain task: transcript events publish sequentially in
1205 // arrival order (see the `TranscriptTap` docs for why one consumer is
1206 // load-bearing). It lives as long as any queue sender does.
1207 //
1208 // The drain COALESCES under the operator's flush policy: the events
1209 // waiting on this queue are committed as batches, so a chatty agent
1210 // costs one storage-tree commit per batch instead of one per event โ
1211 // and one commit is one whole-leaf rewrite (forensics 2026-08-17).
1212 // Arrival order is untouched; only the number of commits changes.
1213 tokio::runtime::Handle::current().spawn(async move {
1214 let dropped = publisher.drain(&mut events, "observability_tap").await;
1215 if dropped > 0 {
1216 tracing::warn!(
1217 dropped,
1218 operation = "observability_tap",
1219 "observability tap: transcript events were not retained"
1220 );
1221 }
1222 });
1223 self.transcript = Some(TranscriptTap { queue });
1224 self
1225 }
1226
1227 /// Set the neutral intervention capability set every worker registering through
1228 /// this notifier advertises (NOI-6, item 4).
1229 ///
1230 /// The composition root wires this from the harness's advertised
1231 /// `AgentSession::capabilities()` so a liminal-connected agent worker's handle
1232 /// carries the primitives its harness supports, which the intervention router
1233 /// gates on. Without this builder the set is empty (observability-only), so a
1234 /// plain activity worker advertises no controls. Pure builder addition, mirroring
1235 /// the registry's capability-carrying registration faรงade.
1236 #[must_use]
1237 pub fn with_intervention_capabilities(
1238 mut self,
1239 capabilities: aion_core::InterventionCapabilities,
1240 ) -> Self {
1241 self.intervention_capabilities = capabilities;
1242 self
1243 }
1244
1245 /// Bind the connection supervisor the notifier pushes through, immediately
1246 /// after it is constructed with this notifier.
1247 ///
1248 /// Returns `true` when the supervisor was stored, `false` when it was already
1249 /// bound (a second bind is a wiring bug and is ignored, never overwriting the
1250 /// live handle). Call this exactly once, right after
1251 /// [`ConnectionSupervisor::with_services_and_notifier`].
1252 pub fn bind_supervisor(&self, supervisor: ConnectionSupervisor) -> bool {
1253 self.supervisor.set(supervisor).is_ok()
1254 }
1255
1256 /// Snapshot of every live liminal connection the dead-man switch must ping:
1257 /// its pid, the worker it registered, and the push leg to reach it.
1258 ///
1259 /// Built from the SAME guard map that owns each connection's registration,
1260 /// so a connection that has closed (its guard removed) is structurally
1261 /// absent from the snapshot and is never pinged. A poisoned guard map is
1262 /// recovered rather than skipped: silently returning no targets would turn
1263 /// the dead-man switch off exactly when the server is already unhealthy.
1264 #[must_use]
1265 pub fn liveness_targets(&self) -> Vec<super::liminal_liveness::LivenessTarget> {
1266 let Some(supervisor) = self.supervisor.get() else {
1267 return Vec::new();
1268 };
1269 let guards = match self.guards.lock() {
1270 Ok(guards) => guards,
1271 Err(poisoned) => poisoned.into_inner(),
1272 };
1273 guards
1274 .iter()
1275 .filter_map(|(pid, guard)| {
1276 guard
1277 .worker_id()
1278 .map(|worker_id| super::liminal_liveness::LivenessTarget {
1279 pid: *pid,
1280 worker_id,
1281 delivery: LiminalWorkerDelivery::new(supervisor.clone(), *pid),
1282 })
1283 })
1284 .collect()
1285 }
1286
1287 /// Refresh one worker's in-flight task stamp from a [`WorkerLivenessBeat`]
1288 /// published on [`WORKER_LIVENESS_CHANNEL`].
1289 ///
1290 /// The worker is resolved by the publishing CONNECTION (`pid` -> the
1291 /// registration guard this notifier owns), never by wire identity, so a
1292 /// beat can only refresh tasks assigned to the worker that sent it. An
1293 /// untracked task is benign (an outbox dispatch the tracker never held, or
1294 /// a beat racing its completion) and is dropped silently; a malformed
1295 /// payload or unregistered connection is logged and dropped โ a bad beat
1296 /// must never tear down the connection callback.
1297 fn record_liveness_beat(&self, pid: u64, payload: &[u8]) {
1298 let Some(tracker) = &self.heartbeat_tracker else {
1299 return;
1300 };
1301 let beat: WorkerLivenessBeat = match serde_json::from_slice(payload) {
1302 Ok(beat) => beat,
1303 Err(error) => {
1304 tracing::warn!(%error, "liveness tap: malformed WorkerLivenessBeat payload");
1305 return;
1306 }
1307 };
1308 let worker_id = match self.guards.lock() {
1309 Ok(guards) => guards.get(&pid).and_then(WorkerRegistration::worker_id),
1310 Err(poisoned) => poisoned
1311 .into_inner()
1312 .get(&pid)
1313 .and_then(WorkerRegistration::worker_id),
1314 };
1315 let Some(worker_id) = worker_id else {
1316 tracing::warn!(
1317 connection_pid = pid,
1318 "liveness tap: beat from a connection with no registered worker"
1319 );
1320 return;
1321 };
1322 let activity_id = ActivityId::from_sequence_position(beat.ordinal);
1323 if let Err(error) = tracker.record_liveness(
1324 worker_id,
1325 &beat.workflow_id,
1326 &activity_id,
1327 std::time::Instant::now(),
1328 ) {
1329 tracing::error!(
1330 %error,
1331 connection_pid = pid,
1332 "liveness tap: heartbeat tracker refresh failed"
1333 );
1334 }
1335 }
1336
1337 /// Apply one worker's [`WorkerCapabilitiesAnnouncement`] published on
1338 /// [`WORKER_CAPABILITIES_CHANNEL`] to its registered handle.
1339 ///
1340 /// The worker is resolved by the publishing CONNECTION (`pid` -> the
1341 /// registration guard this notifier owns), never by wire identity, so an
1342 /// announcement can only ever update the worker that sent it. A malformed
1343 /// payload or unregistered connection is logged and dropped โ a bad
1344 /// announcement must never tear down the connection callback.
1345 ///
1346 /// # The two halves are applied on different terms: ABSENT is not EMPTY
1347 ///
1348 /// `max_concurrency` is applied ALWAYS. Only the worker knows it, the
1349 /// liminal registration frame has no field to carry it, and until it lands
1350 /// the worker is held at capacity unknown and is never dispatched to.
1351 ///
1352 /// `capabilities` is applied only when the worker DECLARES one โ an empty
1353 /// set means "I have none to add", not "clear what you have". There are two
1354 /// writers of that one field: this tap, and the notifier's
1355 /// `with_intervention_capabilities`, which stamps a SERVER-POLICY set at
1356 /// registration that the worker has no way to know. That two-writer shape
1357 /// is pre-existing and out of scope here. What is not is the consequence of
1358 /// this announcement having become unconditional so it could carry capacity:
1359 /// treating an empty set as an instruction would let every worker without
1360 /// harness capabilities of its own silently withdraw the controls an
1361 /// operator had, immediately after connecting and with nothing logged.
1362 /// Nothing is lost by the distinction โ capabilities are fixed for the life
1363 /// of a connection, and a redial re-registers and re-stamps.
1364 fn record_capabilities_announcement(&self, pid: u64, payload: &[u8]) {
1365 // Decoded through a TOLERANT probe, so that "this worker sent no
1366 // capacity" is reported as the condition it is rather than as a parse
1367 // failure. `max_concurrency` is a required field of the announcement, so
1368 // an older worker that predates it produced a `serde` error and a
1369 // "malformed payload" line โ a message that names the wrong problem and
1370 // says nothing about the consequence, which is that this worker will
1371 // never be selected for a dispatch as long as it stays connected.
1372 let announcement: AnnouncementProbe = match serde_json::from_slice(payload) {
1373 Ok(announcement) => announcement,
1374 Err(error) => {
1375 tracing::warn!(
1376 %error,
1377 connection_pid = pid,
1378 "capabilities tap: malformed WorkerCapabilitiesAnnouncement payload"
1379 );
1380 return;
1381 }
1382 };
1383 let Some(max_concurrency) = announcement.max_concurrency else {
1384 tracing::error!(
1385 connection_pid = pid,
1386 "capabilities tap: this worker announced no capacity and will never be selected \
1387 for a dispatch. The liminal registration frame cannot carry a capacity, so the \
1388 server holds a worker at capacity UNKNOWN until it announces one; a worker that \
1389 never does is registered, idle and unselectable. It is an older build that \
1390 predates the capacity announcement, or its announcement was truncated"
1391 );
1392 return;
1393 };
1394 let worker_id = match self.guards.lock() {
1395 Ok(guards) => guards.get(&pid).and_then(WorkerRegistration::worker_id),
1396 Err(poisoned) => poisoned
1397 .into_inner()
1398 .get(&pid)
1399 .and_then(WorkerRegistration::worker_id),
1400 };
1401 let Some(worker_id) = worker_id else {
1402 tracing::warn!(
1403 connection_pid = pid,
1404 "capabilities tap: announcement from a connection with no registered worker"
1405 );
1406 return;
1407 };
1408 // CAPACITY FIRST. It is the field that decides whether this worker is
1409 // dispatchable at all โ it registered with capacity unknown, which
1410 // selection reads as full โ so a failure here must be reported at
1411 // ERROR and must not be masked by the capability update that follows.
1412 match self
1413 .registry
1414 .set_advertised_capacity(worker_id, max_concurrency)
1415 {
1416 Ok(true) => {}
1417 Ok(false) => tracing::warn!(
1418 connection_pid = pid,
1419 worker_id = ?worker_id,
1420 "capabilities tap: capacity announcement raced the worker's deregistration"
1421 ),
1422 Err(error) => tracing::error!(
1423 %error,
1424 connection_pid = pid,
1425 worker_id = ?worker_id,
1426 announced_max_concurrency = max_concurrency,
1427 "capabilities tap: worker announced a capacity the registry refused; it stays \
1428 unknown-capacity and no dispatch will be selected for it"
1429 ),
1430 }
1431 // AN EMPTY CAPABILITY SET DOES NOT CLEAR THE REGISTRATION STAMP.
1432 //
1433 // This announcement used to be sent only by a worker that had
1434 // capabilities to declare; it is unconditional now, because it also
1435 // carries the capacity without which the worker is never dispatched to.
1436 // That made the capability half destructive: the notifier stamps a set
1437 // at REGISTRATION (its `with_intervention_capabilities`, the server's
1438 // own policy for liminal workers, which the worker has no way to know),
1439 // and a worker with no harness capabilities of its own would then
1440 // immediately overwrite it with the empty set โ silently withdrawing
1441 // every intervention control an operator had.
1442 //
1443 // So the two halves of the announcement are applied on different terms,
1444 // and deliberately: the capacity is always applied because only the
1445 // worker knows it, and the capability set is applied only when the
1446 // worker actually declares one. Nothing is lost by that โ capabilities
1447 // are fixed for the life of a connection, and a redial re-registers and
1448 // re-stamps โ and it keeps the exact behaviour that held when silence
1449 // was how a worker said "I have none".
1450 if announcement.capabilities.supported.is_empty() {
1451 return;
1452 }
1453 match self
1454 .registry
1455 .set_intervention_capabilities(worker_id, &announcement.capabilities)
1456 {
1457 Ok(true) => {}
1458 Ok(false) => tracing::warn!(
1459 connection_pid = pid,
1460 worker_id = ?worker_id,
1461 "capabilities tap: announcement raced the worker's deregistration"
1462 ),
1463 Err(error) => tracing::error!(
1464 %error,
1465 connection_pid = pid,
1466 "capabilities tap: registry capability update failed"
1467 ),
1468 }
1469 }
1470
1471 fn validate_registration_contract(
1472 &self,
1473 registration: &WireWorkerRegistration,
1474 ) -> Result<(), LiminalServerError> {
1475 let Some(engine) = &self.contract_catalog else {
1476 return Ok(());
1477 };
1478 let advertised =
1479 registration
1480 .activities
1481 .iter()
1482 .map(|activity| {
1483 let input_schema =
1484 serde_json::from_str(&activity.input_schema_json).map_err(|error| {
1485 LiminalServerError::ListenerAccept {
1486 message: format!(
1487 "liminal worker activity `{}` input schema is invalid: {error}",
1488 activity.name
1489 ),
1490 }
1491 })?;
1492 let output_schema = serde_json::from_str(&activity.output_schema_json)
1493 .map_err(|error| LiminalServerError::ListenerAccept {
1494 message: format!(
1495 "liminal worker activity `{}` output schema is invalid: {error}",
1496 activity.name
1497 ),
1498 })?;
1499 Ok(aion_package::ActivityDescriptor {
1500 name: activity.name.clone(),
1501 input_schema,
1502 output_schema,
1503 })
1504 })
1505 .collect::<Result<Vec<_>, LiminalServerError>>()?;
1506 // Both advertised forms travel into the gate together: the NAME set the
1507 // dispatcher selects on and the CONTRACT set admission compares. A
1508 // refusal computed from one while the record printed the other is what
1509 // produced the 2026-07-30 self-contradicting log.
1510 let activity_types = registration
1511 .activity_types
1512 .iter()
1513 .cloned()
1514 .collect::<std::collections::BTreeSet<_>>();
1515 super::contracts::validate_worker_contracts(
1516 engine,
1517 self.registry.admission_audit(),
1518 ®istration.task_queue,
1519 registration.node.as_deref(),
1520 ®istration.identity,
1521 super::contracts::WorkerAdvertisement {
1522 activity_types: &activity_types,
1523 contracts: &advertised,
1524 },
1525 )
1526 .map_err(|error| LiminalServerError::ListenerAccept {
1527 message: error.to_string(),
1528 })
1529 }
1530
1531 /// Admit one connecting worker past the TRANSPORT's own refusals, or refuse
1532 /// it with a structured reason.
1533 ///
1534 /// Split out of [`ConnectionNotifier::on_worker_registered`] so every
1535 /// refusal this transport owns โ unbound supervisor, registry error, lease
1536 /// failure โ funnels through exactly one place that logs it. Before that
1537 /// split the rejection reason was carried in the `Rejected` ack and logged
1538 /// NOWHERE: on 2026-07-29 a worker whose `.v4` contract field-mismatched was
1539 /// refused on every single dial with the server silent on all of them, and
1540 /// the refused process looked merely asleep.
1541 ///
1542 /// **The contract check is deliberately NOT here.** That 2026-07-29 fix was
1543 /// made in this caller rather than at the shared gate, and within a week the
1544 /// gRPC transport re-manifested the identical silent refusal with nothing in
1545 /// the tree able to notice (#147). The gate now names its own refusal for
1546 /// every transport, so the contract check runs BEFORE this function and its
1547 /// error propagates from here already spoken for.
1548 fn admit_registration(
1549 &self,
1550 pid: u64,
1551 registration: &WireWorkerRegistration,
1552 ) -> Result<(), LiminalServerError> {
1553 // Admit through the SAME gate a gRPC worker passes: guard policy, each
1554 // namespace authorized, mint-or-gate, placement admission, then the
1555 // insert. A notifier with no admission bound refuses โ it never
1556 // inserts unguarded.
1557 let Some(admission) = &self.admission else {
1558 return Err(LiminalServerError::ListenerAccept {
1559 message: format!(
1560 "liminal worker registration for connection {pid} rejected: this listener \
1561 has no namespace admission bound, so it cannot judge which namespaces the \
1562 worker may serve; a listener is commissioned with one before it accepts"
1563 ),
1564 });
1565 };
1566 if admission.auth_enabled {
1567 return Err(LiminalServerError::ListenerAccept {
1568 message: format!(
1569 "liminal worker registration for connection {pid} (identity `{}`) rejected: \
1570 this server authenticates callers (auth.enabled = true) and the liminal \
1571 registration frame carries no credential to authenticate, so the worker's \
1572 namespaces cannot be scoped to a caller; register this worker over gRPC \
1573 with a bearer token, or run the server with auth disabled",
1574 registration.identity
1575 ),
1576 });
1577 }
1578 // The delivery leg needs the supervisor to push to this connection. In
1579 // correct wiring it is bound before any connection is accepted; a missing
1580 // binding is a rejected registration, never a panic.
1581 let supervisor =
1582 self.supervisor
1583 .get()
1584 .ok_or_else(|| LiminalServerError::ListenerAccept {
1585 message: format!(
1586 "liminal worker registration for connection {pid} rejected: \
1587 notifier supervisor handle not yet bound"
1588 ),
1589 })?;
1590
1591 let delivery = WorkerDelivery::Liminal(LiminalWorkerDelivery::new(supervisor.clone(), pid));
1592 let caller = liminal_caller_identity(®istration.identity);
1593 let proto = proto_registration_from_wire(registration);
1594 let guard = bridge_admission(
1595 &admission.handle,
1596 self.registry.admit_delivery(
1597 &admission.guard,
1598 &caller,
1599 &proto,
1600 delivery,
1601 self.intervention_capabilities.clone(),
1602 ),
1603 )
1604 .map_err(|error| LiminalServerError::ListenerAccept {
1605 message: format!("liminal worker registration for connection {pid} rejected: {error}"),
1606 })?
1607 .map_err(|error| LiminalServerError::ListenerAccept {
1608 message: format!("liminal worker registration for connection {pid} rejected: {error}"),
1609 })?;
1610 let worker_id = guard
1611 .worker_id()
1612 .ok_or_else(|| LiminalServerError::ListenerAccept {
1613 message: format!(
1614 "liminal worker registration for connection {pid} has no worker id"
1615 ),
1616 })?;
1617
1618 // OWN the guard for the connection's lifetime, keyed by pid. Dropping it
1619 // (on unregister) deregisters the worker, so the registration lives
1620 // exactly as long as the connection.
1621 let mut guards = self.guards.lock().map_err(|_| {
1622 // The accepted registry entry cannot be tracked for deregistration, so
1623 // reject (and drop the just-created guard, deregistering it) rather
1624 // than leak a never-deregistered association.
1625 LiminalServerError::ListenerAccept {
1626 message: format!(
1627 "liminal worker registration for connection {pid} rejected: \
1628 notifier guard map poisoned"
1629 ),
1630 }
1631 })?;
1632 guards.insert(pid, guard);
1633 drop(guards);
1634 if let Some(tracker) = &self.heartbeat_tracker
1635 && let Err(error) = tracker.register_connection(worker_id, std::time::Instant::now())
1636 {
1637 let removed = match self.guards.lock() {
1638 Ok(mut guards) => guards.remove(&pid),
1639 Err(poisoned) => poisoned.into_inner().remove(&pid),
1640 };
1641 drop(removed);
1642 return Err(LiminalServerError::ListenerAccept {
1643 message: format!(
1644 "liminal worker registration for connection {pid} rejected: {error}"
1645 ),
1646 });
1647 }
1648 tracing::info!(
1649 connection_pid = pid,
1650 identity = %registration.identity,
1651 task_queue = %registration.task_queue,
1652 "registered liminal worker in-band"
1653 );
1654 Ok(())
1655 }
1656}
1657
1658/// The admission a liminal registration is judged by, bound at commissioning.
1659struct RegistrationAdmission {
1660 guard: NamespaceGuard,
1661 auth_enabled: bool,
1662 /// The runtime the async admission is bridged onto from the connection
1663 /// thread โ named at commissioning, never captured from ambient context.
1664 handle: tokio::runtime::Handle,
1665}
1666
1667/// Run the async admission to completion from the synchronous connection
1668/// callback, on whichever thread liminal invokes it from.
1669///
1670/// Three callers, three answers, no panic path:
1671/// - liminal's own connection thread (production) is on no runtime, so the
1672/// future is driven by `handle.block_on` directly;
1673/// - a multi-thread runtime worker (a test that drives the notifier from an
1674/// async body) cannot be blocked without stalling its scheduler, so the
1675/// worker is first handed back via `block_in_place`;
1676/// - a current-thread runtime worker has nothing to hand back to โ blocking
1677/// it would deadlock the very runtime the admission runs on โ so the call
1678/// is refused by name rather than hung.
1679///
1680/// # Errors
1681///
1682/// Returns the refusal for the current-thread case; the admission's own
1683/// verdict rides in the `Ok`.
1684fn bridge_admission<F: Future>(
1685 handle: &tokio::runtime::Handle,
1686 admission: F,
1687) -> Result<F::Output, ServerError> {
1688 match tokio::runtime::Handle::try_current() {
1689 Err(_) => Ok(handle.block_on(admission)),
1690 Ok(current) => match current.runtime_flavor() {
1691 tokio::runtime::RuntimeFlavor::MultiThread => {
1692 Ok(tokio::task::block_in_place(|| handle.block_on(admission)))
1693 }
1694 _ => Err(ServerError::Config {
1695 message: "the liminal registration callback ran on a current-thread Tokio \
1696 runtime worker, which cannot be blocked while the namespace \
1697 admission runs; drive the listener from a multi-thread runtime \
1698 or from a plain thread, as liminal's connection process does"
1699 .to_owned(),
1700 }),
1701 },
1702 }
1703}
1704
1705/// The caller a liminal registration is admitted as on an auth-off server:
1706/// the operator identity, subject = the frame's `identity` (or `operator`
1707/// when the frame left it empty) โ the liminal twin of the gRPC development
1708/// identity built from `x-aion-subject`.
1709fn liminal_caller_identity(identity: &str) -> CallerIdentity {
1710 let subject = identity.trim();
1711 CallerIdentity::operator(if subject.is_empty() {
1712 "operator"
1713 } else {
1714 subject
1715 })
1716}
1717
1718fn wire_activity_to_proto(
1719 activity: &liminal::protocol::WorkerActivityDescriptor,
1720) -> aion_proto::ProtoActivityDescriptor {
1721 aion_proto::ProtoActivityDescriptor {
1722 name: activity.name.clone(),
1723 input_schema_json: activity.input_schema_json.clone(),
1724 output_schema_json: activity.output_schema_json.clone(),
1725 }
1726}
1727
1728/// The liminal registration frame in the shape the namespace guard judges:
1729/// the same fields, the wire's `None` node as the proto's empty string.
1730fn proto_registration_from_wire(
1731 registration: &WireWorkerRegistration,
1732) -> aion_proto::ProtoRegisterWorker {
1733 aion_proto::ProtoRegisterWorker {
1734 namespaces: registration.namespaces.clone(),
1735 activity_types: registration.activity_types.clone(),
1736 task_queue: registration.task_queue.clone(),
1737 node: normalize_wire_node(registration.node.as_deref()).unwrap_or_default(),
1738 activities: registration
1739 .activities
1740 .iter()
1741 .map(wire_activity_to_proto)
1742 .collect(),
1743 identity: registration.identity.clone(),
1744 instance: None,
1745 // ABSENT, and absent means UNKNOWN rather than unlimited. The liminal
1746 // registration frame is a published protocol type whose six fields โ
1747 // namespaces, task queue, node, activity types, identity, activities โ
1748 // contain no capacity, so a liminal worker physically cannot state what
1749 // it runs at once in the frame that registers it. It is the only
1750 // transport in that position.
1751 //
1752 // The server does NOT fill the gap with a number of its own. It used to
1753 // stamp 1 here and have the worker SDK refuse any other configuration,
1754 // which made every liminal worker serial โ and once selection began
1755 // honouring capacity, that serialized the agent path that fleet
1756 // workflows fan across in parallel. The worker's CONFIGURED value now
1757 // arrives one frame later on `WORKER_CAPABILITIES_CHANNEL` and lands via
1758 // `ConnectedWorkerRegistry::set_advertised_capacity`; until then
1759 // selection passes the worker over.
1760 max_concurrency: None,
1761 }
1762}
1763
1764impl ConnectionNotifier for LiminalConnectionNotifier {
1765 fn on_worker_registered(
1766 &self,
1767 pid: u64,
1768 registration: &WireWorkerRegistration,
1769 ) -> Result<(), LiminalServerError> {
1770 // The contract gate NAMES its own refusal, for every transport, with the
1771 // queue, the node, the worker build identity and the structured reason.
1772 // So this path propagates that refusal SILENTLY rather than restating
1773 // it: a second copy of the same log is how #147 happened, and a log
1774 // written twice per refusal is #94's 12 MB/hour with a second author.
1775 self.validate_registration_contract(registration)?;
1776 // Every refusal this TRANSPORT owns is named here instead, at WARN, with
1777 // the same reason that rides back in the `Rejected` ack, so both sides'
1778 // logs carry identical text and a refused worker can be diagnosed from
1779 // either end.
1780 self.admit_registration(pid, registration)
1781 .inspect_err(|error| {
1782 // The two advertised sets are logged SEPARATELY and named for
1783 // what each one is. Logging `activity_types` alone beside a
1784 // contract-mismatch reason produced a record that listed an
1785 // action as advertised in the same line that reported it
1786 // `<missing>` โ true of two different sets, and unresolvable by
1787 // the operator it exists to help.
1788 let advertised_contracts = registration
1789 .activities
1790 .iter()
1791 .map(|activity| activity.name.as_str())
1792 .collect::<Vec<_>>();
1793 tracing::warn!(
1794 connection_pid = pid,
1795 identity = %registration.identity,
1796 task_queue = %registration.task_queue,
1797 namespaces = ?registration.namespaces,
1798 advertised_activity_types = ?registration.activity_types,
1799 advertised_contracts = ?advertised_contracts,
1800 reason = %error,
1801 "REFUSED liminal worker registration"
1802 );
1803 })
1804 }
1805
1806 fn on_worker_unregistered(&self, pid: u64) {
1807 // Remove + drop the guard for pid, deregistering the worker. A poisoned
1808 // lock on the close path has no peer to report to; recover the guard map
1809 // and still drop the guard so the registry does not keep routing to a
1810 // gone connection.
1811 let removed = match self.guards.lock() {
1812 Ok(mut guards) => guards.remove(&pid),
1813 Err(poisoned) => poisoned.into_inner().remove(&pid),
1814 };
1815 let worker_id = removed.as_ref().and_then(WorkerRegistration::worker_id);
1816 if let (Some(tracker), Some(worker_id)) = (&self.heartbeat_tracker, worker_id)
1817 && let Err(error) = tracker.unregister_connection(worker_id)
1818 {
1819 tracing::error!(
1820 %error,
1821 connection_pid = pid,
1822 worker_id = worker_id.value(),
1823 "failed to clear liminal worker connection lease"
1824 );
1825 }
1826 if removed.is_some() {
1827 tracing::info!(
1828 connection_pid = pid,
1829 "deregistered liminal worker on disconnect"
1830 );
1831 }
1832 }
1833
1834 fn on_channel_publish(&self, pid: u64, channel: &str, payload: &[u8]) -> bool {
1835 if let Some(tracker) = &self.heartbeat_tracker {
1836 let worker_id = match self.guards.lock() {
1837 Ok(guards) => guards.get(&pid).and_then(WorkerRegistration::worker_id),
1838 Err(poisoned) => poisoned
1839 .into_inner()
1840 .get(&pid)
1841 .and_then(WorkerRegistration::worker_id),
1842 };
1843 if let Some(worker_id) = worker_id
1844 && let Err(error) =
1845 tracker.record_connection_activity(worker_id, std::time::Instant::now())
1846 {
1847 tracing::error!(
1848 %error,
1849 connection_pid = pid,
1850 worker_id = worker_id.value(),
1851 "failed to advance liminal worker connection lease"
1852 );
1853 }
1854 }
1855 // Reserved liveness channel: refresh the beat's in-flight task stamp in
1856 // the shared heartbeat tracker (always consumed, never fanned out).
1857 if channel == WORKER_LIVENESS_CHANNEL {
1858 self.record_liveness_beat(pid, payload);
1859 return true;
1860 }
1861 // Reserved capabilities channel: apply the worker's advertised
1862 // intervention capabilities to its registered handle (always consumed).
1863 if channel == WORKER_CAPABILITIES_CHANNEL {
1864 self.record_capabilities_announcement(pid, payload);
1865 return true;
1866 }
1867 // Only consume the reserved observability channel; any other channel falls
1868 // through to liminal's normal fan-out (this returns false).
1869 if channel != liminal_sdk::OBSERVABILITY_CHANNEL {
1870 return false;
1871 }
1872 let Some(tap) = &self.transcript else {
1873 // No transcript sequencer installed (a non-agent deployment): still
1874 // CONSUME the reserved channel so it never leaks into the fan-out, but
1875 // drop the event โ there is nothing to persist it into.
1876 return true;
1877 };
1878 let event: aion_core::ActivityEvent = match serde_json::from_slice(payload) {
1879 Ok(event) => event,
1880 Err(error) => {
1881 tracing::warn!(%error, "observability tap: malformed ActivityEvent payload");
1882 return true;
1883 }
1884 };
1885 // Hand the event to the ordered drain queue (the synchronous
1886 // connection-process callback never blocks). A full queue means the
1887 // durable append cannot keep up: the event is dropped with a warning,
1888 // never buffered without bound.
1889 if let Err(error) = tap.queue.try_send(event) {
1890 tracing::warn!(%error, "observability tap: transcript queue rejected event");
1891 }
1892 true
1893 }
1894}
1895
1896/// The production [`InterventionTransport`](super::intervention::InterventionTransport):
1897/// pushes a routed command to the owning worker over its liminal server-push
1898/// connection (NOI-6, ยง6.2).
1899///
1900/// It reads the worker handle's [`WorkerDelivery::Liminal`] leg and pushes the
1901/// neutral [`InterventionRequest`] via [`LiminalWorkerDelivery::push_intervention`],
1902/// running the blocking push off the async runtime. A worker delivered over gRPC
1903/// (no liminal leg) surfaces the stale-target no-op via a connection-lost error, so
1904/// the router NACKs the operator rather than routing to a leg it cannot reach.
1905#[derive(Clone, Debug, Default)]
1906pub struct LiminalInterventionTransport;
1907
1908#[async_trait]
1909impl super::intervention::InterventionTransport for LiminalInterventionTransport {
1910 async fn push(
1911 &self,
1912 worker: &super::registry::WorkerHandle,
1913 command: aion_core::InterventionCommand,
1914 ) -> Result<aion_core::InterventionOutcome, ServerError> {
1915 let delivery = match worker.delivery() {
1916 WorkerDelivery::Liminal(delivery) => delivery.clone(),
1917 WorkerDelivery::Grpc(_) => {
1918 // No liminal leg to push to: the intervention transport rides the
1919 // liminal push channel only, so this is unreachable for the target.
1920 return Err(ServerError::worker_connection_lost(
1921 "liminal-push",
1922 "owning worker is not delivered over liminal".to_owned(),
1923 ));
1924 }
1925 };
1926 let request = InterventionRequest {
1927 intervention: command,
1928 };
1929 let reply = tokio::task::spawn_blocking(move || delivery.push_intervention(&request))
1930 .await
1931 .map_err(|error| {
1932 dispatch_error(
1933 "liminal-push",
1934 format!("intervention task join failed: {error}"),
1935 )
1936 })??;
1937 Ok(reply.outcome)
1938 }
1939}
1940
1941#[cfg(test)]
1942#[path = "liminal_contract_tests.rs"]
1943mod contract_tests;
1944
1945#[cfg(test)]
1946mod tests {
1947 use super::{
1948 AnnouncementProbe, DispatchRequest, LiminalServerError, OUTBOUND_BOUND_KEY,
1949 channel_for_row, classify_push_error, classify_reply_error, dispatch_channel_name,
1950 normalize_wire_node,
1951 };
1952
1953 /// The oversize refusal reaches the REPLY awaiter, not the push call: liminal
1954 /// reserves the slot synchronously and measures the frame afterwards in the
1955 /// connection process. So the reply classifier must name it unservable
1956 /// too โ this arm used to wrap it as the retryable dispatch class, which is
1957 /// why the operator's refusal said "retryable" for a frame no retry could
1958 /// carry.
1959 #[test]
1960 fn an_oversize_frame_on_the_reply_awaiter_is_unservable_and_names_the_bound() {
1961 let error = LiminalServerError::PushFrameExceedsOutboundCapacity {
1962 correlation_id: 11,
1963 needed: 5_089_012,
1964 capacity: 1_048_576,
1965 queued: 0,
1966 };
1967 let classified = classify_reply_error(&error);
1968 assert!(classified.is_worker_dispatch_unservable(), "{classified}");
1969 assert!(!classified.is_worker_connection_lost(), "{classified}");
1970 let said = classified.to_string();
1971 for needle in ["5089012", "1048576", OUTBOUND_BOUND_KEY] {
1972 assert!(said.contains(needle), "refusal must name {needle}: {said}");
1973 }
1974 }
1975
1976 /// The reply classifier keeps its two prior readings exactly: a closed
1977 /// connection is the TRANSPORT-loss class (fail over and retry), and any
1978 /// other reply fault stays the retryable dispatch class.
1979 #[test]
1980 fn a_closed_connection_on_the_reply_awaiter_is_still_a_lost_connection() {
1981 let classified =
1982 classify_reply_error(&LiminalServerError::PushReplyDisconnected { correlation_id: 3 });
1983 assert!(classified.is_worker_connection_lost(), "{classified}");
1984 assert!(!classified.is_worker_dispatch_unservable(), "{classified}");
1985 }
1986
1987 #[test]
1988 fn any_other_reply_fault_stays_the_retryable_dispatch_class() {
1989 let classified =
1990 classify_reply_error(&LiminalServerError::PushReplyExpired { correlation_id: 5 });
1991 assert!(
1992 matches!(classified, crate::error::ServerError::WorkerDispatch { .. }),
1993 "{classified}"
1994 );
1995 assert!(!classified.is_worker_dispatch_unservable(), "{classified}");
1996 assert!(!classified.is_worker_connection_lost(), "{classified}");
1997 }
1998
1999 /// The oversize frame is classified UNSERVABLE, never as a lost connection:
2000 /// the lost arm means "fail over and retry", and retrying a frame that can
2001 /// never fit is the 846-lease fault this arm exists to end. The refusal
2002 /// names both numbers and the operator key.
2003 #[test]
2004 fn an_oversize_frame_is_unservable_and_names_the_bound_and_the_key() {
2005 let error = LiminalServerError::PushFrameExceedsOutboundCapacity {
2006 correlation_id: 7,
2007 needed: 6_214_149,
2008 capacity: 4_194_304,
2009 queued: 0,
2010 };
2011 let classified = classify_push_error(&error);
2012 assert!(classified.is_worker_dispatch_unservable(), "{classified}");
2013 assert!(!classified.is_worker_connection_lost(), "{classified}");
2014 assert!(!classified.is_worker_busy(), "{classified}");
2015 let said = classified.to_string();
2016 for needle in ["6214149", "4194304", OUTBOUND_BOUND_KEY] {
2017 assert!(said.contains(needle), "refusal must name {needle}: {said}");
2018 }
2019 }
2020
2021 /// The two pre-existing classes are untouched: a cap refusal is still BUSY
2022 /// (attempt-neutral park) and every other push failure is still LOST
2023 /// (immediate failover). The new arm narrows nothing but itself.
2024 #[test]
2025 fn the_cap_and_lost_classes_are_unchanged() {
2026 let cap = LiminalServerError::ConnectionCapReached {
2027 operation: "push".to_owned(),
2028 cap: "max_pending_pushes_per_connection",
2029 limit: 32,
2030 };
2031 assert!(classify_push_error(&cap).is_worker_busy());
2032 let other = LiminalServerError::PushReplyDisconnected { correlation_id: 9 };
2033 assert!(classify_push_error(&other).is_worker_connection_lost());
2034 }
2035
2036 /// The key named in the refusal is the key on the config struct: one
2037 /// spelling, pinned, so the operator is never sent to a field that does not
2038 /// exist.
2039 #[test]
2040 fn the_refusal_names_the_real_config_key() -> Result<(), Box<dyn std::error::Error>> {
2041 // Go through serde's field name rather than trusting a second string:
2042 // the struct is `Deserialize`, so a TOML document naming the key must
2043 // land on the field, or the refusal is sending operators to a key that
2044 // does not exist.
2045 let parsed: crate::config::OutboxConfig =
2046 toml::from_str(&format!("{OUTBOUND_BOUND_KEY} = 1"))?;
2047 assert_eq!(parsed.liminal_max_connection_outbound_bytes, Some(1));
2048 Ok(())
2049 }
2050
2051 /// STRUCTURAL TWIN GUARD. `aion_worker::runtime::liminal::DispatchRequest`
2052 /// is a field-for-field mirror of this crate's, kept by hand because the
2053 /// worker crate does not depend on the server. Until now nothing checked
2054 /// it. This does, at compile time (the worker type must exist and decode)
2055 /// and at run time: a frame this side writes re-encodes byte-for-byte from
2056 /// the worker type, the two key sets are equal, and the payload-bytes
2057 /// codec is the same on both sides, so a base64 `input` decodes there to
2058 /// the bytes it decodes to here.
2059 #[test]
2060 fn the_dispatch_request_twins_agree_field_for_field() -> Result<(), Box<dyn std::error::Error>>
2061 {
2062 use base64::Engine as _;
2063 use std::collections::BTreeSet;
2064
2065 let ours = DispatchRequest {
2066 activity_type: "charge-card".to_owned(),
2067 workflow_id: WorkflowId::new(Uuid::new_v4()),
2068 ordinal: 7,
2069 run_id: Some(RunId::new(Uuid::new_v4())),
2070 completion_token: "generation-3".to_owned(),
2071 idempotency_key: "effect-key".to_owned(),
2072 input: br#"{"amount":42}"#.to_vec(),
2073 attempt: 3,
2074 labels: std::collections::BTreeMap::from([("region".to_owned(), "apac".to_owned())]),
2075 heartbeat_window_ms: 30_000,
2076 };
2077 let wire = serde_json::to_value(&ours)?;
2078 let theirs: aion_worker::runtime::liminal::DispatchRequest =
2079 serde_json::from_value(wire.clone())?;
2080 assert_eq!(
2081 serde_json::to_value(&theirs)?,
2082 wire,
2083 "the worker twin must re-encode this frame identically"
2084 );
2085 let keys = |value: &serde_json::Value| -> BTreeSet<String> {
2086 value
2087 .as_object()
2088 .map(|object| object.keys().cloned().collect())
2089 .unwrap_or_default()
2090 };
2091 assert_eq!(keys(&wire), keys(&serde_json::to_value(&theirs)?));
2092 assert_eq!(theirs.input, ours.input);
2093 assert_eq!(theirs.completion_token, ours.completion_token);
2094 assert_eq!(theirs.attempt, ours.attempt);
2095 assert_eq!(theirs.heartbeat_window_ms, ours.heartbeat_window_ms);
2096
2097 let mut as_base64 = wire;
2098 as_base64["input"] = serde_json::Value::String(
2099 base64::engine::general_purpose::STANDARD.encode(&ours.input),
2100 );
2101 let ours_from_base64: DispatchRequest = serde_json::from_value(as_base64.clone())?;
2102 let theirs_from_base64: aion_worker::runtime::liminal::DispatchRequest =
2103 serde_json::from_value(as_base64)?;
2104 assert_eq!(ours_from_base64, ours);
2105 assert_eq!(theirs_from_base64.input, ours.input);
2106 Ok(())
2107 }
2108 use crate::worker::registry::RegistrationOptions;
2109 use aion_core::{ActivityId, ContentType, Payload, RunId, WorkflowId};
2110 use aion_store::{OutboxRow, OutboxStatus};
2111 use chrono::Utc;
2112 use uuid::Uuid;
2113
2114 /// ABSENT is not MALFORMED, and the tap has to be able to tell them apart.
2115 ///
2116 /// `max_concurrency` is a required field of the announcement, so a worker
2117 /// that predates it produced a `serde` error and the tap logged "malformed
2118 /// payload" โ a message that names the wrong problem and says nothing about
2119 /// the consequence, which is that the server holds that worker at capacity
2120 /// unknown and will never select it for a dispatch. The probe decodes the
2121 /// absence instead of failing on it, which is what lets the tap say so.
2122 #[test]
2123 fn an_announcement_without_capacity_decodes_as_absent_not_as_malformed()
2124 -> Result<(), Box<dyn std::error::Error>> {
2125 let pre_capacity = br#"{"capabilities":{"supported":[]}}"#;
2126 let decoded: AnnouncementProbe = serde_json::from_slice(pre_capacity)?;
2127 assert!(
2128 decoded.max_concurrency.is_none(),
2129 "the absence must be readable as absence, so the tap can name the condition and its \
2130 consequence rather than reporting a parse failure"
2131 );
2132
2133 // A CURRENT announcement still carries the number through.
2134 let current = br#"{"capabilities":{"supported":[]},"max_concurrency":3}"#;
2135 let decoded: AnnouncementProbe = serde_json::from_slice(current)?;
2136 assert_eq!(decoded.max_concurrency, Some(3));
2137
2138 // And genuine rubbish is still a decode failure, so tolerating the
2139 // missing field did not turn the probe into something that accepts
2140 // anything.
2141 assert!(
2142 serde_json::from_slice::<AnnouncementProbe>(br#"{"capabilities":7}"#).is_err(),
2143 "a malformed payload must still fail to decode"
2144 );
2145 Ok(())
2146 }
2147
2148 /// The NOI-6 dispatch owner guard RELEASES its binding on drop, on EVERY exit path
2149 /// (reply, error, panic) โ so the attempt-owner back-index tracks exactly the
2150 /// attempts currently in flight. This is the invariant the dispatch path relies on
2151 /// to never leak a finished attempt's owner.
2152 #[tokio::test]
2153 async fn attempt_owner_guard_releases_on_drop() -> Result<(), Box<dyn std::error::Error>> {
2154 use super::super::intervention::{AttemptKey, AttemptOwnerIndex};
2155 use super::super::registry::{ConnectedWorkerRegistry, WorkerDelivery};
2156 use super::AttemptOwnerGuard;
2157
2158 // A real registration yields a real WorkerId (there is no fabricated id).
2159 let registry = ConnectedWorkerRegistry::default();
2160 let (tx, _rx) = tokio::sync::mpsc::channel(1);
2161 let types = [String::from("agent")];
2162 let registration = registry.register_delivery(
2163 [String::from("default")],
2164 String::from("default"),
2165 None,
2166 types.iter(),
2167 WorkerDelivery::Grpc(tx),
2168 RegistrationOptions::identified(
2169 String::from("liminal-transport-worker"),
2170 crate::worker::UNBOUNDED_SENDER_WORKER_CONCURRENCY,
2171 )
2172 .with_intervention_capabilities(aion_core::InterventionCapabilities::none()),
2173 )?;
2174 let worker = registration
2175 .worker_id()
2176 .ok_or("registration must assign a worker id")?;
2177
2178 let owners = AttemptOwnerIndex::new();
2179 let key = AttemptKey::new(
2180 WorkflowId::new(Uuid::nil()),
2181 aion_core::RunId::new(Uuid::from_u128(0x11)),
2182 ActivityId::from_sequence_position(3),
2183 1,
2184 );
2185 owners.bind(key.clone(), worker);
2186 assert_eq!(
2187 owners.owner(&key),
2188 Some(worker),
2189 "owner bound before the guard"
2190 );
2191 {
2192 let _guard = AttemptOwnerGuard {
2193 owners: owners.clone(),
2194 key: key.clone(),
2195 };
2196 assert_eq!(
2197 owners.owner(&key),
2198 Some(worker),
2199 "still bound while in flight"
2200 );
2201 }
2202 // The guard dropped at the end of the block: the binding is released, so a
2203 // later intervention resolves no owner (the too-late no-op).
2204 assert_eq!(
2205 owners.owner(&key),
2206 None,
2207 "owner released when the dispatch returns"
2208 );
2209 Ok(())
2210 }
2211
2212 /// The channel format is pinned EXACTLY: any change is a wire-compatibility
2213 /// break (the dispatcher and any worker subscription must agree byte-for-byte).
2214 /// The UNPINNED (`None`) channel MUST stay byte-identical to the pre-NODE-5
2215 /// format so existing pool subscriptions are stable.
2216 #[test]
2217 fn channel_format_is_pinned() {
2218 assert_eq!(
2219 dispatch_channel_name("remote", "gpu", None),
2220 "aion.dispatch.remote.gpu"
2221 );
2222 assert_eq!(
2223 dispatch_channel_name("local", "norn", None),
2224 "aion.dispatch.local.norn"
2225 );
2226 }
2227
2228 /// A node-pinned dispatch appends the node as an injectively-encoded
2229 /// sub-segment: `f(ns, tq, Some(node))` == `aion.dispatch.{ns}.{tq}.{node}`.
2230 #[test]
2231 fn node_pinned_channel_appends_node_subsegment() {
2232 assert_eq!(
2233 dispatch_channel_name("remote", "gpu", Some("box-7")),
2234 "aion.dispatch.remote.gpu.box-7"
2235 );
2236 }
2237
2238 /// Same input always yields the same channel (the function is stable/total),
2239 /// for both the unpinned and node-pinned cases.
2240 #[test]
2241 fn channel_derivation_is_stable() {
2242 assert_eq!(
2243 dispatch_channel_name("default", "default", None),
2244 dispatch_channel_name("default", "default", None)
2245 );
2246 assert_eq!(
2247 dispatch_channel_name("default", "default", Some("box-1")),
2248 dispatch_channel_name("default", "default", Some("box-1"))
2249 );
2250 }
2251
2252 /// Distinct `(namespace, task_queue)` pools derive distinct channels โ the
2253 /// whole point of NSTQ-5: `(remote, gpu)` and `(local, norn)` never collide.
2254 #[test]
2255 fn distinct_pools_get_distinct_channels() {
2256 assert_ne!(
2257 dispatch_channel_name("remote", "gpu", None),
2258 dispatch_channel_name("local", "norn", None)
2259 );
2260 }
2261
2262 /// A node-pinned dispatch and the unpinned dispatch for the SAME pool derive
2263 /// DISTINCT channels, and two distinct nodes for the same pool also differ โ
2264 /// the property node isolation rests on (the subscriber contract).
2265 #[test]
2266 fn node_pin_separates_channels() {
2267 let unpinned = dispatch_channel_name("remote", "gpu", None);
2268 let box7 = dispatch_channel_name("remote", "gpu", Some("box-7"));
2269 let box8 = dispatch_channel_name("remote", "gpu", Some("box-8"));
2270 assert_ne!(
2271 unpinned, box7,
2272 "pinned dispatch must not reach unpinned pool"
2273 );
2274 assert_ne!(box7, box8, "distinct nodes must not collide");
2275 }
2276
2277 /// The core injectivity property: free-form fields containing the segment
2278 /// separator `.` must NOT bleed across the join. With the raw `format!` the
2279 /// disjoint pools `("a.b", "c")` and `("a", "b.c")` both collapsed onto
2280 /// `aion.dispatch.a.b.c` โ a cross-pool/cross-namespace leak. The per-segment
2281 /// encode keeps them distinct.
2282 #[test]
2283 fn dotted_fields_do_not_collide_across_segments() {
2284 assert_ne!(
2285 dispatch_channel_name("a.b", "c", None),
2286 dispatch_channel_name("a", "b.c", None),
2287 "a '.' in a field must not bleed across the segment separator"
2288 );
2289 }
2290
2291 /// Injectivity holds ACROSS segment counts: a 2-segment (unpinned) channel
2292 /// can never be confused with a 3-segment (node-pinned) channel even when a
2293 /// `.` in a field would otherwise make the raw strings line up. Both
2294 /// directions of the brief's collision cases must stay distinct.
2295 #[test]
2296 fn node_subsegment_does_not_collide_with_dotted_fields() {
2297 // A node sub-segment vs the same dot living inside task_queue.
2298 assert_ne!(
2299 dispatch_channel_name("a", "b", Some("c")),
2300 dispatch_channel_name("a", "b.c", None),
2301 "a node sub-segment must not collide with a dotted task_queue"
2302 );
2303 // The dot living inside namespace vs a node sub-segment.
2304 assert_ne!(
2305 dispatch_channel_name("a.b", "c", None),
2306 dispatch_channel_name("a", "b", Some("c")),
2307 "a dotted namespace must not collide with a node-pinned channel"
2308 );
2309 }
2310
2311 /// More reserved-char shifts that the raw `format!` collapsed but the encode
2312 /// must keep distinct โ the dot can sit on either side of the boundary.
2313 #[test]
2314 fn reserved_char_shifts_stay_distinct() {
2315 // Dot at the end of namespace vs start of task_queue.
2316 assert_ne!(
2317 dispatch_channel_name("ns.", "tq", None),
2318 dispatch_channel_name("ns", ".tq", None)
2319 );
2320 // Empty field vs the dot living in the other field.
2321 assert_ne!(
2322 dispatch_channel_name("", "a.b", None),
2323 dispatch_channel_name(".a", "b", None)
2324 );
2325 // The escape char itself must not let a literal `%2E` impersonate an
2326 // encoded `.`: `("%2E", "x")` (literal percent-two-E) must differ from
2327 // `(".", "x")` (an actual dot, which encodes to `%2E`).
2328 assert_ne!(
2329 dispatch_channel_name("%2E", "x", None),
2330 dispatch_channel_name(".", "x", None)
2331 );
2332 }
2333
2334 /// Encoding is injective in ALL THREE segments independently and is exactly
2335 /// reversible (the property the channel relies on), so a small exhaustive
2336 /// sweep of reserved-char arrangements โ INCLUDING the optional node taking
2337 /// `None` and every reserved-char value โ yields all-distinct channels. This
2338 /// covers cross-segment-count collisions (the `None` vs `Some` boundary) too.
2339 #[test]
2340 fn encoding_is_injective_over_reserved_char_triples() {
2341 let fields = ["a", "a.b", "a.", ".a", ".", "", "%", "%2E", "a%b", "%2."];
2342 let nodes = [
2343 None,
2344 Some("a"),
2345 Some("a.b"),
2346 Some("."),
2347 Some(""),
2348 Some("%2E"),
2349 ];
2350 let mut channels = std::collections::HashSet::new();
2351 for ns in fields {
2352 for tq in fields {
2353 for node in nodes {
2354 let channel = dispatch_channel_name(ns, tq, node);
2355 assert!(
2356 channels.insert(channel.clone()),
2357 "collision on ({ns:?}, {tq:?}, {node:?}) -> {channel}"
2358 );
2359 }
2360 }
2361 }
2362 }
2363
2364 fn row(namespace: &str, task_queue: &str) -> OutboxRow {
2365 let workflow_id = WorkflowId::new(Uuid::new_v4());
2366 OutboxRow {
2367 dispatch_key: format!("{workflow_id}:0"),
2368 workflow_id,
2369 ordinal: 0,
2370 run_id: Some(aion_core::RunId::new_v4()),
2371 namespace: namespace.to_owned(),
2372 task_queue: task_queue.to_owned(),
2373 node: None,
2374 activity_type: "charge-card".to_owned(),
2375 input: Payload::new(ContentType::Json, Vec::new()),
2376 status: OutboxStatus::Pending,
2377 attempt: 0,
2378 started_attempt: 1,
2379 visible_after: Utc::now(),
2380 claimed_at: None,
2381 failure_delivered: false,
2382 }
2383 }
2384
2385 /// A row's channel is derived from its durable `(namespace, task_queue)`
2386 /// columns (NSTQ-2), through the same single derivation function โ and
2387 /// `activity_type` does NOT enter the channel. With `node = None` the channel
2388 /// is byte-identical to the pre-NODE-5 2-segment form.
2389 #[test]
2390 fn channel_for_row_uses_namespace_and_task_queue_only() {
2391 let remote_gpu = row("remote", "gpu");
2392 let local_norn = row("local", "norn");
2393 assert_eq!(channel_for_row(&remote_gpu), "aion.dispatch.remote.gpu");
2394 assert_eq!(channel_for_row(&local_norn), "aion.dispatch.local.norn");
2395 assert_ne!(channel_for_row(&remote_gpu), channel_for_row(&local_norn));
2396
2397 // Two rows that differ ONLY in activity_type derive the SAME channel:
2398 // activity_type is matched after delivery, not used to select the pool.
2399 let mut other_activity = row("remote", "gpu");
2400 other_activity.activity_type = "refund".to_owned();
2401 assert_eq!(
2402 channel_for_row(&remote_gpu),
2403 channel_for_row(&other_activity),
2404 "activity_type must not affect the channel"
2405 );
2406 }
2407
2408 /// A row carrying `Some(node)` (NODE-2) derives the node-pinned sub-channel,
2409 /// distinct from the same pool's unpinned channel; a row with `None` derives
2410 /// the 2-segment channel. `channel_for_row` threads `row.node` through the
2411 /// single derivation function.
2412 #[test]
2413 fn channel_for_row_derives_node_subchannel_when_pinned() {
2414 let mut pinned = row("remote", "gpu");
2415 pinned.node = Some("box-7".to_owned());
2416 assert_eq!(channel_for_row(&pinned), "aion.dispatch.remote.gpu.box-7");
2417
2418 let unpinned = row("remote", "gpu");
2419 assert_eq!(channel_for_row(&unpinned), "aion.dispatch.remote.gpu");
2420 assert_ne!(channel_for_row(&pinned), channel_for_row(&unpinned));
2421 }
2422
2423 /// The wire `node` is normalized onto the registry's optional affinity with
2424 /// the SAME none-convention the gRPC registration path uses: `None` and the
2425 /// empty-string node both collapse to unpinned (`None`), a non-empty value is
2426 /// the advertised node. An empty-string node must NOT register a distinct
2427 /// empty affinity no pinned dispatch could match.
2428 #[test]
2429 fn wire_node_normalizes_empty_to_none() {
2430 assert_eq!(normalize_wire_node(None), None);
2431 assert_eq!(normalize_wire_node(Some("")), None);
2432 assert_eq!(normalize_wire_node(Some("box-7")), Some("box-7".to_owned()));
2433 }
2434}