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