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//! - [`RegistryLiminalDispatch`] implements
30//! [`OutboxRowDispatch`](super::outbox_dispatcher::OutboxRowDispatch): for each
31//! claimed row it selects a worker from the connected-worker registry, pushes
32//! the [`DispatchRequest`] to that worker's liminal connection via its
33//! [`LiminalWorkerDelivery`], and re-enters the worker's [`DispatchResponse`]
34//! through the SAME [`LiminalCompletionSource`] / [`OutboxDeliveryCallback`] the
35//! gRPC completion path uses. A row that reaches no matching worker, or whose
36//! worker is not liminal-delivered, returns an error so the outbox's unchanged
37//! retry/backoff drives it — the same honest no-worker contract as the gRPC
38//! path.
39//! - [`LiminalConnectionNotifier`] is the SERVER half of in-band registration:
40//! when a worker connects with a [`WorkerRegistration`](WireWorkerRegistration)
41//! the notifier inserts a [`WorkerDelivery::Liminal`] into the registry, and
42//! drops it on disconnect.
43//! - [`LiminalCompletionSource`] maps a [`DispatchResponse`] onto the delivery
44//! callback, threading `run_id` end-to-end so the existing continue-as-new run
45//! gates apply unchanged.
46//!
47//! # The channel-subscription seam (documented, distinct from the push path)
48//!
49//! [`dispatch_channel_name`] derives the pool channel a `(namespace, task_queue)`
50//! pool addresses, optionally pinned to a `node` (NODE-2/NODE-5). The production
51//! path above does not publish to that channel — it pushes to a connected worker
52//! the server already owns — but the derivation is retained as the pinned contract
53//! any future channel-subscription transport MUST honour so the dispatcher and a
54//! subscriber cannot drift:
55//!
56//! - An UNPINNED worker pool addressed `(namespace, task_queue)` subscribes to
57//! `dispatch_channel_name(namespace, task_queue, None)`.
58//! - A NODE-PINNED dispatch (the row carries `Some(node)`) maps to
59//! `dispatch_channel_name(namespace, task_queue, Some(node))` — a DISTINCT
60//! channel that a worker on that node must ALSO subscribe to in order to serve
61//! pinned work; the unpinned channel alone never delivers a pinned dispatch.
62//!
63//! That is the single contract the seam must honour.
64
65use std::collections::HashMap;
66use std::sync::{Arc, Mutex, OnceLock};
67use std::time::Duration;
68
69use aion_core::{ActivityId, ContentType, Payload, RunId, WorkflowId};
70use aion_store::OutboxRow;
71use async_trait::async_trait;
72use liminal::protocol::WorkerRegistration as WireWorkerRegistration;
73use liminal_sdk::{SchemaMetadata, SchemaValidate};
74use liminal_server::ServerError as LiminalServerError;
75use liminal_server::server::connection::{
76 ConnectionNotifier, ConnectionSupervisor, PushReplyAwaiter,
77};
78use serde::{Deserialize, Serialize};
79
80use super::bridge::OutboxDeliveryCallback;
81use super::outbox_dispatcher::OutboxRowDispatch;
82use super::registry::{ConnectedWorkerRegistry, WorkerDelivery, WorkerHandle, WorkerRegistration};
83use crate::error::ServerError;
84
85/// Upper bound on how long a server-initiated dispatch push waits for the
86/// worker's correlated reply before the row is treated as undelivered and the
87/// outbox retries. Generous because an activity may legitimately run a while; the
88/// outbox's own retry/reconcile loop is the real liveness backstop.
89const PUSH_REPLY_TIMEOUT: Duration = Duration::from_secs(30);
90
91/// Re-arm cadence for the engine-seam bridge's UNBOUNDED reply wait
92/// ([`receive_bridge_reply`]). Each elapsed poll is a benign re-arm, never a
93/// failure: the bridge dispatch contract imposes no activity timeout of its own
94/// (agent-style activities legitimately run for over an hour), exactly like the
95/// gRPC bridge's unbounded `recv`. Worker loss still terminates the wait
96/// promptly — the awaiter wakes with the typed Disconnected error the moment the
97/// connection closes.
98const BRIDGE_REPLY_POLL: Duration = Duration::from_secs(1);
99
100/// Wire request carrying one scheduled activity to a liminal worker.
101///
102/// Mirrors the dispatch half of the gRPC `ActivityTask`: the fields the worker
103/// needs to execute the activity and to correlate its result back to the exact
104/// execution (`workflow_id`, `ordinal`, `run_id`). `run_id` rides end-to-end so
105/// the existing continue-as-new run gates hold over the liminal wire (the design
106/// doc §3.3 requirement that `RunId` stays on the wire).
107#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
108pub struct DispatchRequest {
109 /// Activity type the worker must execute.
110 pub activity_type: String,
111 /// Workflow that scheduled this fan-out activity. Carried in its serde form
112 /// so no fragile id parsing happens on the wire.
113 pub workflow_id: WorkflowId,
114 /// Pinned ordinal of this activity within the workflow's fan-out range.
115 pub ordinal: u64,
116 /// Run that dispatched this ordinal, when known (continue-as-new safety).
117 pub run_id: Option<RunId>,
118 /// Opaque activity input bytes (JSON-tagged on the aion side).
119 pub input: Vec<u8>,
120 /// One-based delivery attempt, mirroring the gRPC `ActivityTask.attempt`.
121 /// The engine-seam bridge threads the real attempt so a retry executes with
122 /// attempt-aware handler semantics identical to the gRPC transport; the
123 /// outbox path stamps the row's stored zero-based attempt as one-based.
124 /// Serde-defaulted to `1` so a frame from a pre-attempt server (or an old
125 /// recorded frame) still decodes as a first delivery.
126 #[serde(default = "first_attempt")]
127 pub attempt: u32,
128 /// Engine-provided routing/metadata labels, mirroring the gRPC
129 /// `ActivityTask.labels`. Empty (the serde default) on the outbox path,
130 /// which has no label source.
131 #[serde(default)]
132 pub labels: std::collections::BTreeMap<String, String>,
133 /// The server's heartbeat window in milliseconds when this dispatch is
134 /// tracked by the server's per-task liveness tracker (the engine-seam
135 /// bridge path), or `0` when it is not (the outbox path, whose liveness
136 /// backstop is its own retry loop). A non-zero window tells the worker to
137 /// pump automatic liveness beats at a quarter-window cadence so the
138 /// server's heartbeat sweeper never expires a healthy long-running
139 /// activity — the exact liminal mirror of the gRPC worker's automatic
140 /// liveness pump.
141 #[serde(default)]
142 pub heartbeat_window_ms: u64,
143}
144
145/// Serde default for [`DispatchRequest::attempt`]: a frame that predates the
146/// attempt field is a first delivery.
147const fn first_attempt() -> u32 {
148 1
149}
150
151impl SchemaValidate for DispatchRequest {
152 fn schema_metadata() -> SchemaMetadata {
153 SchemaMetadata::new(
154 "aion.outbox.dispatch.request",
155 "1",
156 br#"{"type":"object"}"#.as_slice(),
157 )
158 }
159}
160
161/// Wire response carrying one worker result back to the outbox.
162///
163/// Mirrors the completion half of the gRPC `ActivityResult`: the correlation ids
164/// plus either a success result or a failure reason. `LiminalCompletionSource`
165/// maps this onto the existing [`OutboxDeliveryCallback`].
166#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
167pub struct DispatchResponse {
168 /// Workflow the completion belongs to.
169 pub workflow_id: WorkflowId,
170 /// Pinned ordinal the completion correlates against.
171 pub ordinal: u64,
172 /// Run that issued the dispatch, echoed back for the run gate.
173 pub run_id: Option<RunId>,
174 /// Worker outcome: `Ok(result)` or `Err(reason)`.
175 pub outcome: Result<String, String>,
176}
177
178impl SchemaValidate for DispatchResponse {
179 fn schema_metadata() -> SchemaMetadata {
180 SchemaMetadata::new(
181 "aion.outbox.dispatch.response",
182 "1",
183 br#"{"type":"object"}"#.as_slice(),
184 )
185 }
186}
187
188/// Wire request carrying one neutral mid-run intervention command to a liminal
189/// worker (NOI-6, §6.2).
190///
191/// Rides the SAME liminal server-push channel as [`DispatchRequest`], distinguished
192/// on the wire by its unique required `intervention` field — a plain
193/// [`DispatchRequest`] has no such field, so the worker demuxes the two by which
194/// one deserializes. The whole envelope is neutral: it carries an
195/// [`InterventionCommand`], never a harness type. Field-for-field mirrored by the
196/// worker's `liminal::InterventionRequest`.
197#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
198pub struct InterventionRequest {
199 /// The neutral command to route to the worker owning the target attempt.
200 pub intervention: aion_core::InterventionCommand,
201}
202
203impl SchemaValidate for InterventionRequest {
204 fn schema_metadata() -> SchemaMetadata {
205 SchemaMetadata::new(
206 "aion.intervention.request",
207 "1",
208 br#"{"type":"object"}"#.as_slice(),
209 )
210 }
211}
212
213/// Wire response carrying the worker's neutral intervention ack back to the server
214/// (NOI-6). Field-for-field mirrored by the worker's `liminal::InterventionReply`.
215#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
216pub struct InterventionReply {
217 /// The neutral applied/gated/stale outcome the operator receives.
218 pub outcome: aion_core::InterventionOutcome,
219}
220
221impl SchemaValidate for InterventionReply {
222 fn schema_metadata() -> SchemaMetadata {
223 SchemaMetadata::new(
224 "aion.intervention.reply",
225 "1",
226 br#"{"type":"object"}"#.as_slice(),
227 )
228 }
229}
230
231/// Reserved liminal channel a worker publishes automatic liveness beats on.
232///
233/// The liminal wire has no gRPC-style heartbeat frame, so per-task liveness
234/// rides a reserved publish channel exactly as the observability transcript
235/// does: the worker's runtime pumps a [`WorkerLivenessBeat`] per in-flight
236/// tracked dispatch at a quarter-window cadence, and the server's
237/// [`LiminalConnectionNotifier`] consumes the channel and refreshes the shared
238/// [`HeartbeatTracker`] — so the #176 expiry sweeper is genuinely
239/// transport-agnostic: a healthy liminal worker running a long activity is
240/// never falsely expired, and a wedged one (which stops pumping) still is.
241/// Mirrored byte-for-byte by the worker crate's constant of the same name.
242pub const WORKER_LIVENESS_CHANNEL: &str = "aion.worker.liveness";
243
244/// Reserved liminal channel a worker announces its intervention capabilities on.
245///
246/// The in-band [`WireWorkerRegistration`] frame is a published liminal protocol
247/// type and cannot carry aion-level capability metadata, so a worker whose
248/// harness supports interventions publishes a [`WorkerCapabilitiesAnnouncement`]
249/// here immediately after registering (once per connection, so a redialed
250/// worker re-announces). The notifier consumes the channel and applies the
251/// announcement to the registered handle — the set the intervention router
252/// gates on and the ops console's live-attempts enumeration reports. Mirrored
253/// byte-for-byte by the worker crate's constant of the same name.
254pub const WORKER_CAPABILITIES_CHANNEL: &str = "aion.worker.capabilities";
255
256/// Wire announcement of a worker's advertised intervention capabilities.
257///
258/// Field-for-field mirror of the worker crate's `WorkerCapabilitiesAnnouncement`
259/// (the same cross-crate contract the dispatch/response pairs pin). The worker
260/// is identified by its CONNECTION (the publish's pid resolves the registered
261/// worker), never by wire-supplied identity, so an announcement can only ever
262/// apply to the worker that sent it.
263#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
264pub struct WorkerCapabilitiesAnnouncement {
265 /// The neutral intervention primitives the worker's harness supports.
266 pub capabilities: aion_core::InterventionCapabilities,
267}
268
269/// Wire liveness beat for one in-flight dispatch (the liminal mirror of the
270/// gRPC `Heartbeat` frame, liveness-only — progress payloads are not carried).
271///
272/// Field-for-field mirror of the worker crate's `WorkerLivenessBeat` (same
273/// serde field names + `aion-core` id types), the same cross-crate contract the
274/// dispatch/response pairs pin. The worker is identified by its CONNECTION (the
275/// publish's pid resolves the registered worker), never by wire-supplied
276/// identity, so a beat can only ever refresh tasks of the worker that sent it.
277#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
278pub struct WorkerLivenessBeat {
279 /// Workflow owning the in-flight activity being kept alive.
280 pub workflow_id: WorkflowId,
281 /// Pinned ordinal of the in-flight activity being kept alive.
282 pub ordinal: u64,
283}
284
285/// Builds the wire request for one claimed outbox row.
286///
287/// Kept free-standing (not a method) so both the dispatch path and tests build
288/// the request the same way.
289#[must_use]
290pub fn request_for_row(row: &OutboxRow) -> DispatchRequest {
291 DispatchRequest {
292 activity_type: row.activity_type.clone(),
293 workflow_id: row.workflow_id.clone(),
294 ordinal: row.ordinal,
295 run_id: row.run_id.clone(),
296 input: row.input.bytes().to_vec(),
297 // The stored zero-based attempt is stamped one-based on the wire (zero
298 // is malformed), exactly as the gRPC outbox arm's `to_scheduled` does.
299 attempt: row.attempt.saturating_add(1),
300 // Outbox rows carry no engine labels (the gRPC arm sends empty too).
301 labels: std::collections::BTreeMap::new(),
302 // Outbox dispatches are not tracked by the server's per-task liveness
303 // tracker — the outbox retry loop is their liveness backstop — so no
304 // window is assigned and the worker does not pump beats for them.
305 heartbeat_window_ms: 0,
306 }
307}
308
309/// The single reserved character that separates channel segments. Because
310/// `namespace`/`task_queue` are free-form, any occurrence of this byte INSIDE a
311/// segment must be escaped so it cannot be mistaken for the segment boundary.
312const SEGMENT_SEPARATOR: char = '.';
313
314/// The escape character used by [`encode_segment`]. It must itself be escaped so
315/// the encoding stays injective (otherwise `%2E` as a literal field value would
316/// collide with an encoded `.`).
317const SEGMENT_ESCAPE: char = '%';
318
319/// Percent-encodes the two reserved characters (`.` and `%`) inside one channel
320/// segment so distinct segment values can never collide across the join.
321///
322/// This is a minimal, deterministic, per-segment escape: a literal `.` becomes
323/// `%2E` and a literal `%` becomes `%25`; every other byte (including the empty
324/// string) passes through unchanged. Because both the separator AND the escape
325/// char are encoded, the mapping `value -> encoded` is injective: it is exactly
326/// reversible by replacing `%2E -> .` and `%25 -> %`, so two distinct values
327/// can never encode to the same string. Dot-free, percent-free inputs (the
328/// normal case, e.g. `"remote"`, `"gpu"`) are returned byte-for-byte unchanged,
329/// so existing channels are stable.
330fn encode_segment(segment: &str) -> String {
331 // Fast path: nothing reserved, return an owned copy unchanged.
332 if !segment.contains([SEGMENT_SEPARATOR, SEGMENT_ESCAPE]) {
333 return segment.to_owned();
334 }
335 let mut encoded = String::with_capacity(segment.len());
336 for ch in segment.chars() {
337 match ch {
338 // Encode the escape char FIRST so an already-present `%` cannot be
339 // confused with one we introduce for the separator.
340 SEGMENT_ESCAPE => encoded.push_str("%25"),
341 SEGMENT_SEPARATOR => encoded.push_str("%2E"),
342 other => encoded.push(other),
343 }
344 }
345 encoded
346}
347
348/// Derives the liminal dispatch channel for a worker pool addressed
349/// `(namespace, task_queue)`, optionally pinned to a specific `node`.
350///
351/// This is the **single, total source of truth** for the channel string: every
352/// site that needs the channel a `(namespace, task_queue[, node])` pool
353/// dispatches to — both this dispatcher and any future worker-pool subscription
354/// side — MUST call this function so the two sides cannot drift. The format is
355/// `"aion.dispatch.{namespace}.{task_queue}"` for an unpinned dispatch and
356/// `"aion.dispatch.{namespace}.{task_queue}.{node}"` when a `node` is pinned;
357/// each `{segment}` is independently passed through [`encode_segment`].
358///
359/// # The subscriber contract (the seam this function pins, NODE-5 / 13-x)
360///
361/// The subscriber side remains the documented seam (it does not exist yet; 13-0
362/// uses liminal's in-server echo responder). The contract both sides MUST honour:
363///
364/// - An **unpinned** worker pool addressed `(namespace, task_queue)` subscribes
365/// to `dispatch_channel_name(namespace, task_queue, None)` and receives every
366/// unpinned dispatch for that pool.
367/// - A **node-pinned** dispatch (the row carries `Some(node)`) goes to
368/// `dispatch_channel_name(namespace, task_queue, Some(node))`, a DISTINCT
369/// channel. A worker running on that node which is meant to serve pinned work
370/// for the pool MUST ALSO subscribe to that node-specific channel — the
371/// `None` channel alone will never deliver a node-pinned dispatch to it.
372///
373/// Because the `None` channel and any `Some(node)` channel are distinct strings,
374/// a node-pinned dispatch never reaches an unpinned-only subscriber and vice
375/// versa; node isolation is therefore enforced by the channel string itself.
376///
377/// # Injectivity (why the per-segment encode matters)
378///
379/// `namespace`, `task_queue` and `node` are all free-form (the design forbids
380/// preset categories), so a raw `format!` would be NON-injective: a `.` inside
381/// any field bleeds across the separator and pools the design declares disjoint
382/// collide onto one channel — e.g. `("a.b", "c", None)` and `("a", "b.c", None)`
383/// would both yield `aion.dispatch.a.b.c`, a cross-pool leak on the very
384/// isolation dimension this routing exists to keep separate. Encoding each
385/// segment independently (the separator `.` and the escape `%` are escaped within
386/// a segment) makes the map from `(namespace, task_queue, node)` to channel
387/// string injective: distinct triples always yield distinct channels, ACROSS
388/// segment counts too. The node segment is appended only for `Some(node)`, and
389/// because no encoded segment can contain a bare separator, a 2-segment channel
390/// (unpinned) can never be confused with a 3-segment channel (pinned) — e.g.
391/// `("a", "b", Some("c"))` and `("a", "b.c", None)` stay distinct, as do
392/// `("a.b", "c", None)` and `("a", "b", Some("c"))`.
393///
394/// `activity_type` is deliberately NOT part of the channel: it is *what to run*,
395/// matched by the worker after delivery (it rides inside [`DispatchRequest`]),
396/// not *which pool* — see `docs/NAMESPACE-TASKQUEUE-SPLIT-DESIGN.md` §4.2. The
397/// function is total (defined for every input) and stable (the same
398/// `(namespace, task_queue, node)` always yields the same channel).
399#[must_use]
400pub fn dispatch_channel_name(namespace: &str, task_queue: &str, node: Option<&str>) -> String {
401 let namespace = encode_segment(namespace);
402 let task_queue = encode_segment(task_queue);
403 match node {
404 Some(node) => {
405 let node = encode_segment(node);
406 format!("aion.dispatch.{namespace}.{task_queue}.{node}")
407 }
408 None => format!("aion.dispatch.{namespace}.{task_queue}"),
409 }
410}
411
412/// Derives the liminal dispatch channel for a claimed outbox row.
413///
414/// Thin wrapper over [`dispatch_channel_name`] reading the row's durable
415/// `(namespace, task_queue)` (NSTQ-2 columns) and its optional `node` (NODE-2):
416/// when `row.node` is `Some`, the row dispatches to the node-pinned sub-channel;
417/// when `None`, it derives the byte-identical unpinned channel. Kept
418/// free-standing so the dispatch path and tests derive the row's channel
419/// identically.
420#[must_use]
421pub fn channel_for_row(row: &OutboxRow) -> String {
422 dispatch_channel_name(&row.namespace, &row.task_queue, row.node.as_deref())
423}
424
425/// Wraps a reason in the existing worker-dispatch error so a non-deliverable
426/// dispatch drives the outbox's unchanged retry/backoff/dead-letter path. The
427/// row-derived `channel` is surfaced as the `activity_type` field for operator
428/// diagnostics (the field is a free-form context string on this transport's
429/// error).
430fn dispatch_error(channel: &str, reason: String) -> ServerError {
431 ServerError::WorkerDispatch {
432 namespace: "liminal".to_owned(),
433 activity_type: channel.to_owned(),
434 reason,
435 }
436}
437
438/// Receives one worker result over liminal and re-enters it into aion.
439///
440/// Holds the installed [`OutboxDeliveryCallback`] (the same prod
441/// `ServerOutboxDeliveryCallback` the gRPC completion path uses) and maps a
442/// [`DispatchResponse`] onto it, threading `run_id` so the continue-as-new run
443/// gates apply unchanged.
444pub struct LiminalCompletionSource {
445 callback: Arc<dyn OutboxDeliveryCallback>,
446}
447
448impl std::fmt::Debug for LiminalCompletionSource {
449 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
450 f.debug_struct("LiminalCompletionSource")
451 .finish_non_exhaustive()
452 }
453}
454
455impl LiminalCompletionSource {
456 /// Build a completion source over the shared outbox delivery callback.
457 #[must_use]
458 pub fn new(callback: Arc<dyn OutboxDeliveryCallback>) -> Self {
459 Self { callback }
460 }
461
462 /// Re-enter one worker result into aion through the delivery callback.
463 ///
464 /// Returns the callback's `bool`: `true` when delivered to a live run,
465 /// `false` when no run is live (the expected stale-completion drop that
466 /// recovery re-arms). A success outcome routes to `deliver_completion`; a
467 /// failure outcome to `deliver_failure`.
468 ///
469 /// # Errors
470 ///
471 /// Returns [`ServerError`] when the response carries an unparseable id or
472 /// the engine rejects the delivery.
473 pub fn deliver(&self, response: &DispatchResponse) -> Result<bool, ServerError> {
474 let activity_id = ActivityId::from_sequence_position(response.ordinal);
475 match &response.outcome {
476 Ok(result) => self.callback.deliver_completion(
477 &response.workflow_id,
478 &activity_id,
479 response.run_id.as_ref(),
480 result.clone(),
481 ),
482 Err(reason) => self.callback.deliver_failure(
483 &response.workflow_id,
484 &activity_id,
485 response.run_id.as_ref(),
486 reason.clone(),
487 ),
488 }
489 }
490}
491
492/// Rebuilds the activity input payload from the wire request.
493///
494/// The aion side tags activity input as JSON; the wire carries the raw bytes, so
495/// a worker (or the test responder standing in for one) reconstructs the typed
496/// [`Payload`] with the JSON content type.
497#[must_use]
498pub fn payload_from_request(request: &DispatchRequest) -> Payload {
499 Payload::new(ContentType::Json, request.input.clone())
500}
501
502/// Delivery handle for a liminal-connected worker held in the worker registry.
503///
504/// A worker that connects over liminal is a first-class registry member selected
505/// the SAME way as a gRPC worker (`select_worker` on `(namespace, task_queue,
506/// node)`); this is the delivery leg the registry holds for it. It pairs the
507/// [`ConnectionSupervisor`] that owns the worker's connection with that
508/// connection's beamr `pid`, so [`Self::dispatch`] can push a [`DispatchRequest`]
509/// out on the worker's existing socket (the LSUB-0 server-push primitive) and
510/// block for the correlated [`DispatchResponse`].
511#[derive(Clone)]
512pub struct LiminalWorkerDelivery {
513 supervisor: ConnectionSupervisor,
514 pid: u64,
515}
516
517impl std::fmt::Debug for LiminalWorkerDelivery {
518 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
519 f.debug_struct("LiminalWorkerDelivery")
520 .field("pid", &self.pid)
521 .finish_non_exhaustive()
522 }
523}
524
525impl LiminalWorkerDelivery {
526 /// Build a delivery handle for the worker reachable on connection `pid`
527 /// through `supervisor`.
528 #[must_use]
529 pub const fn new(supervisor: ConnectionSupervisor, pid: u64) -> Self {
530 Self { supervisor, pid }
531 }
532
533 /// The connection pid this worker is addressed on.
534 #[must_use]
535 pub const fn pid(&self) -> u64 {
536 self.pid
537 }
538
539 /// Push one dispatch out on the worker's connection and block for its reply.
540 ///
541 /// Serializes `request`, pushes it via [`ConnectionSupervisor::push_to_connection`],
542 /// and decodes the worker's correlated [`DispatchResponse`] reply.
543 ///
544 /// # Error classification (LSUB-3)
545 ///
546 /// Two of the failure paths mean the chosen worker's connection is GONE, and
547 /// they surface the typed [`ServerError::WorkerConnectionLost`] so the outbox
548 /// can fail over immediately rather than waiting out the retry backoff:
549 ///
550 /// - `push_to_connection` returns `Err` only when the connection process is no
551 /// longer live (the connection was already gone at push time). It has no
552 /// other failure mode, so that whole arm is connection-lost.
553 /// - `awaiter.receive` returns the typed liminal
554 /// `ServerError::PushReplyDisconnected` when the connection closed before a
555 /// correlated reply arrived (after Stage A this wakes PROMPTLY instead of
556 /// blocking the full [`PUSH_REPLY_TIMEOUT`]), and `PushReplyTimeout` when the
557 /// worker is alive but slow. [`is_connection_closed_reply_error`] matches the
558 /// Disconnected variant BY TYPE; the Timeout variant (and anything else)
559 /// stays on the existing [`ServerError::WorkerDispatch`] backoff path — the
560 /// two are never collapsed.
561 ///
562 /// Every other failure (serialize, decode, or any unrecognized reply error)
563 /// remains a [`ServerError::WorkerDispatch`] so the outbox's unchanged
564 /// backoff/dead-letter path drives the row.
565 ///
566 /// # Errors
567 ///
568 /// Returns [`ServerError::WorkerConnectionLost`] when the worker connection was
569 /// gone at push time or closed before replying; returns
570 /// [`ServerError::WorkerDispatch`] when the request cannot be serialized, the
571 /// reply does not arrive within [`PUSH_REPLY_TIMEOUT`] (slow worker), or the
572 /// reply cannot be decoded.
573 pub fn dispatch(&self, request: &DispatchRequest) -> Result<DispatchResponse, ServerError> {
574 let awaiter = self.push_dispatch(request)?;
575 let reply = awaiter.receive(PUSH_REPLY_TIMEOUT).map_err(|error| {
576 // Disconnected (worker died mid-flight) => connection-lost => immediate
577 // failover. Timeout (worker alive but slow) and anything unrecognized
578 // => WorkerDispatch => unchanged backoff. Never collapse the two.
579 if is_connection_closed_reply_error(&error) {
580 ServerError::worker_connection_lost(
581 "liminal-push",
582 format!("worker connection closed before reply: {error}"),
583 )
584 } else {
585 dispatch_error("liminal-push", format!("worker reply failed: {error}"))
586 }
587 })?;
588 decode_dispatch_response(&reply)
589 }
590
591 /// Serialize and push one dispatch out on the worker's connection, returning
592 /// the awaiter for its correlated reply — the shared push half of both
593 /// dispatch waits: [`Self::dispatch`] (the outbox path, bounded by
594 /// [`PUSH_REPLY_TIMEOUT`] because the outbox's retry loop re-drives an
595 /// undelivered row) and the engine-seam bridge dispatcher (which owns the
596 /// UNBOUNDED wait of [`receive_bridge_reply`], matching the gRPC bridge
597 /// contract). One frame format, one push primitive, two wait policies.
598 ///
599 /// # Errors
600 ///
601 /// Returns [`ServerError::WorkerConnectionLost`] when the connection process
602 /// is no longer live at push time (a push-enqueue failure has exactly one
603 /// cause in liminal — the worker is already gone), and
604 /// [`ServerError::WorkerDispatch`] when the request cannot be serialized.
605 pub(crate) fn push_dispatch(
606 &self,
607 request: &DispatchRequest,
608 ) -> Result<PushReplyAwaiter, ServerError> {
609 let payload = serde_json::to_vec(request).map_err(|error| {
610 dispatch_error("liminal-push", format!("request serialize failed: {error}"))
611 })?;
612 self.supervisor
613 .push_to_connection(self.pid, payload)
614 .map_err(|error| {
615 ServerError::worker_connection_lost(
616 "liminal-push",
617 format!("push to worker failed: {error}"),
618 )
619 })
620 }
621
622 /// Push one neutral intervention command out on the worker's connection and
623 /// block for its correlated ack reply (NOI-6, §6.2).
624 ///
625 /// Mirrors [`Self::dispatch`] but carries an [`InterventionRequest`] and decodes
626 /// an [`InterventionReply`], so an intervention rides the SAME server-push
627 /// channel as an activity dispatch. The push is a blocking, thread-based liminal
628 /// call; the async router runs it off the runtime.
629 ///
630 /// # Errors
631 ///
632 /// Returns [`ServerError::WorkerConnectionLost`] when the worker connection was
633 /// gone at push time or closed before replying (so the router surfaces the
634 /// too-late no-op); returns [`ServerError::WorkerDispatch`] when the request
635 /// cannot be serialized, the reply times out, or the reply cannot be decoded.
636 pub fn push_intervention(
637 &self,
638 request: &InterventionRequest,
639 ) -> Result<InterventionReply, ServerError> {
640 let payload = serde_json::to_vec(request).map_err(|error| {
641 dispatch_error(
642 "liminal-push",
643 format!("intervention serialize failed: {error}"),
644 )
645 })?;
646 let awaiter = self
647 .supervisor
648 .push_to_connection(self.pid, payload)
649 .map_err(|error| {
650 ServerError::worker_connection_lost(
651 "liminal-push",
652 format!("push intervention to worker failed: {error}"),
653 )
654 })?;
655 let reply = awaiter.receive(PUSH_REPLY_TIMEOUT).map_err(|error| {
656 if is_connection_closed_reply_error(&error) {
657 ServerError::worker_connection_lost(
658 "liminal-push",
659 format!("worker connection closed before intervention ack: {error}"),
660 )
661 } else {
662 dispatch_error("liminal-push", format!("intervention ack failed: {error}"))
663 }
664 })?;
665 serde_json::from_slice(&reply).map_err(|error| {
666 dispatch_error(
667 "liminal-push",
668 format!("intervention ack decode failed: {error}"),
669 )
670 })
671 }
672}
673
674/// Decodes one correlated reply payload as a [`DispatchResponse`].
675///
676/// Shared by the outbox wait ([`LiminalWorkerDelivery::dispatch`]) and the
677/// bridge wait ([`receive_bridge_reply`]) so the two paths can never diverge on
678/// the wire's reply shape.
679fn decode_dispatch_response(reply: &[u8]) -> Result<DispatchResponse, ServerError> {
680 serde_json::from_slice(reply).map_err(|error| {
681 dispatch_error(
682 "liminal-push",
683 format!("worker reply decode failed: {error}"),
684 )
685 })
686}
687
688/// Blocks for the correlated reply to an engine-seam BRIDGE dispatch push, with
689/// the bridge's UNBOUNDED wait contract: the engine imposes no activity timeout
690/// of its own, so an elapsed [`BRIDGE_REPLY_POLL`] merely re-arms the wait — the
691/// exact liminal mirror of the gRPC bridge's unbounded `recv`, which is released
692/// only by a completion or by stream teardown. The wait terminates on exactly:
693///
694/// - **the reply** — decoded as the worker's [`DispatchResponse`] and returned
695/// as `Ok(Some(response))`. A reply already buffered when a poll fires always
696/// wins over abandonment: the awaiter is drained before `keep_waiting` runs;
697/// - **abandonment** — `keep_waiting()` returned `false` at a poll boundary
698/// (the bridge passes "is this dispatch still tracked in-flight?"): the
699/// dispatch was resolved by another path (expiry sweep, shutdown drain, or a
700/// cleanup), so the wait returns `Ok(None)` and the router thread exits
701/// instead of parking on the connection indefinitely;
702/// - **worker loss** — the connection closed before replying: the awaiter wakes
703/// PROMPTLY with liminal's typed Disconnected error, surfaced as
704/// [`ServerError::WorkerConnectionLost`] so the bridge reports the same
705/// retryable lost-worker failure the gRPC teardown sweep does;
706/// - **an unrecognized receive fault or a decode failure** — surfaced as
707/// [`ServerError::WorkerDispatch`].
708///
709/// Runs on a dedicated bridge reply thread, never on an async runtime worker.
710///
711/// # Errors
712///
713/// Returns [`ServerError::WorkerConnectionLost`] on worker loss and
714/// [`ServerError::WorkerDispatch`] on a receive fault or reply decode failure.
715pub(crate) fn receive_bridge_reply(
716 awaiter: &PushReplyAwaiter,
717 keep_waiting: impl Fn() -> bool,
718) -> Result<Option<DispatchResponse>, ServerError> {
719 loop {
720 match awaiter.receive(BRIDGE_REPLY_POLL) {
721 Ok(reply) => return decode_dispatch_response(&reply).map(Some),
722 // A bare poll timeout re-arms the wait while the dispatch is still
723 // outstanding (the wait is unbounded by contract; see the bridge
724 // module docs), and ends it cleanly once the dispatch was resolved
725 // elsewhere — the poll cadence bounds the router thread's lifetime
726 // to one poll past that resolution.
727 Err(LiminalServerError::PushReplyTimeout { .. }) => {
728 if !keep_waiting() {
729 return Ok(None);
730 }
731 }
732 Err(error) if is_connection_closed_reply_error(&error) => {
733 return Err(ServerError::worker_connection_lost(
734 "liminal-push",
735 format!("worker connection closed before reply: {error}"),
736 ));
737 }
738 Err(error) => {
739 return Err(dispatch_error(
740 "liminal-push",
741 format!("worker reply failed: {error}"),
742 ));
743 }
744 }
745 }
746}
747
748/// Returns true when a liminal push-reply error is the *Disconnected* case (the
749/// worker's connection closed before it replied), as opposed to a genuine reply
750/// timeout (the worker is alive but slow).
751///
752/// Liminal returns these as distinct TYPED variants —
753/// `ServerError::PushReplyDisconnected` vs `PushReplyTimeout` (see `liminal-server`
754/// `supervisor.rs` `PushReplyAwaiter::receive`) — so this is a type match, not a
755/// message-text match: a worker that DIED (connection-lost, fast failover) is told
756/// apart from one that is merely SLOW (genuine timeout, normal backoff) by variant.
757fn is_connection_closed_reply_error(error: &LiminalServerError) -> bool {
758 matches!(error, LiminalServerError::PushReplyDisconnected { .. })
759}
760
761/// Cross-node [`OutboxRowDispatch`] that selects a liminal worker from the
762/// connected-worker registry and pushes the row to it.
763///
764/// This is the LSUB-1 server-side composition: for each claimed row it selects a
765/// worker by the row's `(namespace, task_queue, activity_type, node)` via the
766/// EXISTING registry `select_worker` (the same selection the gRPC path uses, so
767/// routing semantics are shared), pushes the [`DispatchRequest`] to that worker's
768/// liminal connection via its [`LiminalWorkerDelivery`], and re-enters the
769/// worker's [`DispatchResponse`] through the SAME [`LiminalCompletionSource`] /
770/// [`OutboxDeliveryCallback`] the existing completion path uses. A row that
771/// reaches no matching worker, or whose worker is not liminal-delivered, returns
772/// an error so the outbox's unchanged retry/backoff drives it — the same honest
773/// no-worker contract as the gRPC path.
774pub struct RegistryLiminalDispatch {
775 registry: ConnectedWorkerRegistry,
776 completion: LiminalCompletionSource,
777 /// Optional short-TTL per-namespace placement cache (Control-Plane Phase 2,
778 /// P2-P3), the SAME cache the gRPC
779 /// [`WorkerOutboxDispatch`](crate::worker::WorkerOutboxDispatch) is given. When
780 /// present, an UNPINNED row (`row.node == None`) whose namespace placement is
781 /// `Prefer{L}` selects an L-labelled worker and spills to any live worker when
782 /// none is up, via the SHARED
783 /// [`preferred_node_order`](crate::worker::preferred_node_order). When absent
784 /// (the default, every pre-Phase-2 construction and test) selection is
785 /// byte-identical to before: one `select_worker` off the row's own node.
786 /// Placement is NEVER stamped back onto the row — it is consulted only here,
787 /// in this non-replayed dispatcher, for worker selection.
788 placement_cache: Option<crate::worker::PlacementCache>,
789 /// Optional NOI-6 `attempt -> owning-worker` back-index. When installed via
790 /// [`Self::with_attempt_owners`], each dispatched agent attempt binds its
791 /// `(workflow, activity, attempt)` to the selected worker here BEFORE the push and
792 /// releases it after the reply, so the server's intervention router resolves the
793 /// CURRENT owner of a live attempt. `None` (the default, and every non-agent
794 /// deployment) skips the binding — intervention is simply never offered.
795 attempt_owners: Option<super::intervention::AttemptOwnerIndex>,
796}
797
798impl std::fmt::Debug for RegistryLiminalDispatch {
799 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
800 f.debug_struct("RegistryLiminalDispatch")
801 .field("placement_cache", &self.placement_cache.is_some())
802 .finish_non_exhaustive()
803 }
804}
805
806impl RegistryLiminalDispatch {
807 /// Build a registry-backed liminal dispatch that re-enters worker results
808 /// through `callback` (the shared `ServerOutboxDeliveryCallback`).
809 #[must_use]
810 pub fn new(
811 registry: ConnectedWorkerRegistry,
812 callback: Arc<dyn OutboxDeliveryCallback>,
813 ) -> Self {
814 Self {
815 registry,
816 completion: LiminalCompletionSource::new(callback),
817 placement_cache: None,
818 attempt_owners: None,
819 }
820 }
821
822 /// Install the NOI-6 attempt-owner back-index so each dispatched attempt binds
823 /// its owning worker for the intervention router to resolve (NOI-6).
824 ///
825 /// The SAME index the server's [`InterventionRouter`](super::intervention::InterventionRouter)
826 /// resolves through (from `ServerState::attempt_owners`), so a pushed command
827 /// reaches the worker this dispatcher sent the attempt to. Pure builder addition:
828 /// without it, no ownership is recorded and the router finds no owner (the
829 /// too-late no-op), exactly as before.
830 #[must_use]
831 pub fn with_attempt_owners(
832 mut self,
833 attempt_owners: super::intervention::AttemptOwnerIndex,
834 ) -> Self {
835 self.attempt_owners = Some(attempt_owners);
836 self
837 }
838
839 /// Attach the per-namespace placement cache so an unpinned row consults its
840 /// namespace's `Prefer` directive at selection time (Control-Plane Phase 2,
841 /// P2-P3) — the liminal mirror of
842 /// [`WorkerOutboxDispatch::with_placement_cache`](crate::worker::WorkerOutboxDispatch::with_placement_cache).
843 /// Pure builder addition: without it, selection is byte-identical to the
844 /// pre-Phase-2 behaviour.
845 #[must_use]
846 pub fn with_placement_cache(mut self, cache: crate::worker::PlacementCache) -> Self {
847 self.placement_cache = Some(cache);
848 self
849 }
850
851 /// Select the liminal worker for `row`, applying the SHARED placement decision
852 /// for an UNPINNED row when a placement cache is attached — the exact gRPC
853 /// semantics ([`worker_selection_for`](crate::worker::worker_selection_for)):
854 /// `Prefer{L}` spills to any live worker, `Pinned{L}` requires an L-labelled
855 /// worker and NEVER spills to a node=None any-worker.
856 ///
857 /// A per-activity authored pin (`row.node == Some(N)`) ALWAYS wins and is
858 /// selected off the row's own node, untouched by placement — exactly the gRPC
859 /// composition rule. Without a cache (or with a pinned row) this collapses to
860 /// the single `select_worker` off the row's own node — the pre-Phase-2
861 /// behaviour.
862 ///
863 /// For `Pinned{L}`, when no L-labelled worker is live this returns `Ok(None)` —
864 /// NOT a spill to a node=None worker — so the [`OutboxRowDispatch`] surfaces the
865 /// honest no-worker error and the outbox retries/stalls until an L-labelled
866 /// worker returns, mirroring the gRPC wait-for-worker path exactly (both
867 /// transports agree via [`WorkerSelection`](crate::worker::WorkerSelection)).
868 async fn select_liminal_worker(
869 &self,
870 row: &OutboxRow,
871 ) -> Result<Option<WorkerHandle>, ServerError> {
872 // A pinned row or an absent cache: one selection off the row's own node.
873 let (Some(cache), None) = (&self.placement_cache, &row.node) else {
874 return self.registry.select_worker(
875 &row.namespace,
876 &row.task_queue,
877 &row.activity_type,
878 row.node.as_deref(),
879 );
880 };
881 // Unpinned + placement-aware: resolve the shared selection decision, so this
882 // liminal path and the gRPC path can never diverge on Prefer-vs-Pinned. The
883 // row's `node` is never mutated — selection is a pure dispatch-time input.
884 let placement = cache.placement(&row.namespace).await;
885 match crate::worker::worker_selection_for(&placement) {
886 // Prefer/Unplaced: walk the prefer-then-spill tiers (the `None` spill is
887 // always last), stopping at the first tier with a live worker.
888 crate::worker::WorkerSelection::PreferTiers(tiers) => {
889 self.select_over_tiers(row, tiers.iter().map(Option::as_deref))
890 }
891 // Pinned{L}: try ONLY the required labels — no `None` spill. When none is
892 // live, return None so the caller retries/stalls (never any-node).
893 crate::worker::WorkerSelection::Required(required) => self.select_over_tiers(
894 row,
895 required.iter().map(|label| Some(String::as_str(label))),
896 ),
897 }
898 }
899
900 /// Select the first live worker over an ordered sequence of node filters,
901 /// returning `Ok(None)` when no filter matches a live worker. Shared by the
902 /// `Prefer` (tiers end in a `None` spill) and `Pinned` (required labels only,
903 /// no spill) selection arms so both walk the registry identically.
904 fn select_over_tiers<'a>(
905 &self,
906 row: &OutboxRow,
907 tiers: impl Iterator<Item = Option<&'a str>>,
908 ) -> Result<Option<WorkerHandle>, ServerError> {
909 for tier in tiers {
910 let selected = self.registry.select_worker(
911 &row.namespace,
912 &row.task_queue,
913 &row.activity_type,
914 tier,
915 )?;
916 if selected.is_some() {
917 return Ok(selected);
918 }
919 }
920 Ok(None)
921 }
922}
923
924#[async_trait]
925impl OutboxRowDispatch for RegistryLiminalDispatch {
926 async fn dispatch(&self, row: &OutboxRow) -> Result<(), ServerError> {
927 // Select the worker the SAME way the gRPC path does: by the row's
928 // (namespace, task_queue, activity_type) pool key with the row's optional
929 // node affinity, applying the SHARED `Prefer` two-tier spill for an
930 // unpinned row when a placement cache is attached. No worker for the pool
931 // => honest no-worker error => the outbox retries (never a false `done`).
932 let worker = self.select_liminal_worker(row).await?.ok_or_else(|| {
933 dispatch_error(
934 &channel_for_row(row),
935 "no liminal worker registered for the row's pool".to_owned(),
936 )
937 })?;
938
939 let delivery = match worker.delivery() {
940 WorkerDelivery::Liminal(delivery) => delivery.clone(),
941 WorkerDelivery::Grpc(_) => {
942 return Err(dispatch_error(
943 &channel_for_row(row),
944 "selected worker is not delivered over liminal".to_owned(),
945 ));
946 }
947 };
948
949 // NOI-6: bind this attempt's owner BEFORE the push, so an intervention that
950 // races the dispatch resolves the worker. The guard releases on every exit
951 // path (reply, error, panic) so the index never keeps a finished attempt.
952 // The key mirrors the worker's execute-path stamp exactly: activity_id from
953 // the ordinal, attempt = the wire's one-based delivery attempt (the same
954 // `request_for_row` stamp the worker echoes into its session key). See
955 // `LiminalActivityWorker::execute` / `run_agent_dispatch`.
956 let _owner_guard = self.attempt_owners.as_ref().map(|owners| {
957 AttemptOwnerGuard::bind(
958 owners.clone(),
959 super::intervention::AttemptKey::new(
960 row.workflow_id.clone(),
961 ActivityId::from_sequence_position(row.ordinal),
962 row.attempt.saturating_add(1),
963 ),
964 worker.id(),
965 )
966 });
967
968 // Push the dispatch to the worker and block for its correlated reply. The
969 // push is a blocking, thread-based liminal call; run it off the async
970 // runtime so a long-running activity cannot starve a runtime worker.
971 let request = request_for_row(row);
972 let response = tokio::task::spawn_blocking(move || delivery.dispatch(&request))
973 .await
974 .map_err(|error| {
975 dispatch_error(
976 &channel_for_row(row),
977 format!("dispatch task join failed: {error}"),
978 )
979 })??;
980
981 // Re-enter the worker's result through the SAME completion path the gRPC
982 // transport uses (terminal dedup in `record_fan_out_completion` applies
983 // unchanged). The dispatch itself succeeded — the row's terminal state is
984 // recorded by the completion callback, exactly as in the gRPC path.
985 self.completion.deliver(&response)?;
986 Ok(())
987 }
988}
989
990/// RAII guard that releases an [`AttemptOwnerIndex`](super::intervention::AttemptOwnerIndex)
991/// binding when the dispatch resolves — on the reply, an error, or a panic — so
992/// the back-index tracks exactly the attempts currently in flight (NOI-6).
993///
994/// Shared by both liminal dispatch arms: the outbox row wait holds it across
995/// its blocking `dispatch` call, and the engine-seam bridge hands it to the
996/// dispatch's reply-router thread, which drops it on every exit path.
997pub(crate) struct AttemptOwnerGuard {
998 owners: super::intervention::AttemptOwnerIndex,
999 key: super::intervention::AttemptKey,
1000}
1001
1002impl AttemptOwnerGuard {
1003 /// Bind `key` to `worker` in `owners` and return the guard that releases
1004 /// the binding on drop.
1005 pub(crate) fn bind(
1006 owners: super::intervention::AttemptOwnerIndex,
1007 key: super::intervention::AttemptKey,
1008 worker: super::registry::WorkerId,
1009 ) -> Self {
1010 owners.bind(key.clone(), worker);
1011 Self { owners, key }
1012 }
1013}
1014
1015impl Drop for AttemptOwnerGuard {
1016 fn drop(&mut self) {
1017 self.owners.release(&self.key);
1018 }
1019}
1020
1021/// Normalize a wire `node` (`Option<String>`) onto the registry's optional
1022/// locality affinity, applying the SAME none-convention the gRPC registration
1023/// path uses (`registry::optional_node`): an empty string carries no node, so it
1024/// collapses to `None`; any non-empty value is the worker's advertised node.
1025///
1026/// The wire already models `node` as `Option<String>`, but a worker that joins
1027/// `Some("")` (the empty-string node) must not register a distinct empty-node
1028/// affinity that no pinned dispatch could ever match — it is semantically
1029/// unpinned, exactly as the gRPC proto3 empty default is. Folding it to `None`
1030/// here keeps the two registration paths byte-for-byte equivalent.
1031fn normalize_wire_node(node: Option<&str>) -> Option<String> {
1032 node.filter(|value| !value.is_empty())
1033 .map(ToOwned::to_owned)
1034}
1035
1036/// Connection-keyed [`ConnectionNotifier`] that turns liminal's in-band worker
1037/// registration into a first-class [`ConnectedWorkerRegistry`] membership.
1038///
1039/// This is the SERVER half of LSUB-L2: when a worker connects with a
1040/// [`WireWorkerRegistration`] (the SDK's `connect_with_registration`), liminal's
1041/// connection process invokes [`on_worker_registered`](Self::on_worker_registered)
1042/// with the connection's beamr `pid` and the worker's declared
1043/// `(namespaces, task_queue, node, activity_types)`. The notifier builds a
1044/// [`WorkerDelivery::Liminal`] over the connection and inserts it into the
1045/// registry — the SAME registry entry, selected the SAME way, as a gRPC worker —
1046/// retiring the LSUB-1 out-of-band `active_connection_pids()` + hard-coded
1047/// registration hack.
1048///
1049/// # Lifetime of the registration guard
1050///
1051/// [`ConnectedWorkerRegistry::register_delivery`] returns a
1052/// [`WorkerRegistration`] guard whose drop deregisters the worker. The notifier
1053/// OWNS that guard keyed by `pid` (`Mutex<HashMap<u64, WorkerRegistration>>`), so
1054/// the registration lives exactly as long as the connection: it is inserted on
1055/// register and removed (dropped) on
1056/// [`on_worker_unregistered`](Self::on_worker_unregistered), which liminal fires
1057/// on connection close.
1058///
1059/// # Construction-order cycle (notifier <-> supervisor)
1060///
1061/// [`Self::dispatch`'s delivery] needs a [`ConnectionSupervisor`] handle to push
1062/// to the worker's connection, but the supervisor is itself constructed WITH this
1063/// notifier ([`ConnectionSupervisor::with_services_and_notifier`]) — a cycle. The
1064/// notifier therefore holds the supervisor behind a [`OnceLock`], populated
1065/// IMMEDIATELY after the supervisor is built via [`Self::bind_supervisor`]. The
1066/// `OnceLock` is never read before it is set in correct wiring (a worker can only
1067/// register after the listener — built after the supervisor and after
1068/// `bind_supervisor` — accepts its connection); if it somehow were, registration
1069/// is REJECTED with a typed error rather than panicking, so there is no
1070/// production `unwrap`/`expect` and no second always-`None` code path.
1071pub struct LiminalConnectionNotifier {
1072 registry: ConnectedWorkerRegistry,
1073 supervisor: OnceLock<ConnectionSupervisor>,
1074 guards: Mutex<HashMap<u64, WorkerRegistration>>,
1075 /// The neutral intervention primitives a liminal-connected agent worker
1076 /// advertises (NOI-6, item 4). The liminal `WorkerRegistration` wire has a fixed
1077 /// shape that cannot carry this, so it is configured on the notifier at the
1078 /// composition root from the harness's advertised `AgentSession::capabilities()`
1079 /// and recorded on every registered worker's handle, where the intervention
1080 /// router gates on it. Default empty = observability-only (a plain activity
1081 /// worker), so the router offers no controls for it.
1082 intervention_capabilities: aion_core::InterventionCapabilities,
1083 /// The transcript sequencer a worker's observability publishes drain into
1084 /// (NOI-5b), plus the Tokio [`Handle`](tokio::runtime::Handle) to bridge the
1085 /// synchronous connection-process callback onto the async publish. `None` (the
1086 /// default, and every non-agent boot) makes the observability tap a no-op, so a
1087 /// worker publish to the reserved channel is simply ignored by the notifier.
1088 transcript: Option<TranscriptTap>,
1089 /// The SAME per-task liveness tracker the bridge dispatcher tracks into and
1090 /// the #176 expiry sweeper expires from. A worker's automatic
1091 /// [`WorkerLivenessBeat`] publishes on [`WORKER_LIVENESS_CHANNEL`] refresh
1092 /// their task stamps here, keeping the sweeper honest for liminal-delivered
1093 /// dispatches. `None` (a wiring that never bridges dispatches, e.g. isolated
1094 /// tests) consumes and drops the beats.
1095 heartbeat_tracker: Option<super::heartbeat::HeartbeatTracker>,
1096}
1097
1098/// The observability-drain leg of the notifier: the transcript sequencer to publish
1099/// into and the runtime handle used to spawn the async publish from the synchronous
1100/// `on_channel_publish` callback (which runs on the beamr connection-process thread).
1101#[derive(Clone)]
1102struct TranscriptTap {
1103 publisher: crate::activity_publisher::ActivityEventPublisher,
1104 runtime: tokio::runtime::Handle,
1105}
1106
1107impl std::fmt::Debug for LiminalConnectionNotifier {
1108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1109 f.debug_struct("LiminalConnectionNotifier")
1110 .field("supervisor_bound", &self.supervisor.get().is_some())
1111 .finish_non_exhaustive()
1112 }
1113}
1114
1115impl LiminalConnectionNotifier {
1116 /// Build a notifier that registers connecting workers into `registry`.
1117 ///
1118 /// The supervisor handle is bound separately via [`Self::bind_supervisor`]
1119 /// immediately after the supervisor is constructed, resolving the
1120 /// notifier <-> supervisor construction cycle (see the type docs).
1121 #[must_use]
1122 pub fn new(registry: ConnectedWorkerRegistry) -> Self {
1123 Self {
1124 registry,
1125 supervisor: OnceLock::new(),
1126 guards: Mutex::new(HashMap::new()),
1127 intervention_capabilities: aion_core::InterventionCapabilities::none(),
1128 transcript: None,
1129 heartbeat_tracker: None,
1130 }
1131 }
1132
1133 /// Install the shared per-task liveness tracker so a worker's automatic
1134 /// [`WorkerLivenessBeat`] publishes refresh the SAME in-flight entries the
1135 /// bridge dispatcher tracks and the #176 sweeper expires.
1136 ///
1137 /// MUST be wired on every boot that hosts the engine-seam bridge (the
1138 /// production composition does), or the sweeper would expire healthy
1139 /// liminal workers running activities longer than the heartbeat window.
1140 /// Without it (isolated tests that never bridge-dispatch) beats are
1141 /// consumed and dropped.
1142 #[must_use]
1143 pub fn with_heartbeat_tracker(mut self, tracker: super::heartbeat::HeartbeatTracker) -> Self {
1144 self.heartbeat_tracker = Some(tracker);
1145 self
1146 }
1147
1148 /// Install the transcript sequencer a worker's observability publishes drain into
1149 /// (NOI-5b), capturing the CURRENT Tokio runtime handle to bridge the synchronous
1150 /// connection-process callback onto the async publish.
1151 ///
1152 /// MUST be called from within a Tokio runtime (the server boot path is), so the
1153 /// captured [`Handle`](tokio::runtime::Handle) can spawn the append+fan-out when a
1154 /// worker publishes a transcript event over the reserved channel. Without this
1155 /// builder the observability tap is a no-op (a plain, non-agent deployment).
1156 ///
1157 /// # Panics
1158 ///
1159 /// Panics if called outside a Tokio runtime — a construction-time wiring error in
1160 /// the server boot, never a runtime condition (the boot path always builds the
1161 /// notifier inside the server runtime).
1162 #[must_use]
1163 pub fn with_transcript_publisher(
1164 mut self,
1165 publisher: crate::activity_publisher::ActivityEventPublisher,
1166 ) -> Self {
1167 self.transcript = Some(TranscriptTap {
1168 publisher,
1169 runtime: tokio::runtime::Handle::current(),
1170 });
1171 self
1172 }
1173
1174 /// Set the neutral intervention capability set every worker registering through
1175 /// this notifier advertises (NOI-6, item 4).
1176 ///
1177 /// The composition root wires this from the harness's advertised
1178 /// `AgentSession::capabilities()` so a liminal-connected agent worker's handle
1179 /// carries the primitives its harness supports, which the intervention router
1180 /// gates on. Without this builder the set is empty (observability-only), so a
1181 /// plain activity worker advertises no controls. Pure builder addition, mirroring
1182 /// the registry's capability-carrying registration façade.
1183 #[must_use]
1184 pub fn with_intervention_capabilities(
1185 mut self,
1186 capabilities: aion_core::InterventionCapabilities,
1187 ) -> Self {
1188 self.intervention_capabilities = capabilities;
1189 self
1190 }
1191
1192 /// Bind the connection supervisor the notifier pushes through, immediately
1193 /// after it is constructed with this notifier.
1194 ///
1195 /// Returns `true` when the supervisor was stored, `false` when it was already
1196 /// bound (a second bind is a wiring bug and is ignored, never overwriting the
1197 /// live handle). Call this exactly once, right after
1198 /// [`ConnectionSupervisor::with_services_and_notifier`].
1199 pub fn bind_supervisor(&self, supervisor: ConnectionSupervisor) -> bool {
1200 self.supervisor.set(supervisor).is_ok()
1201 }
1202
1203 /// Refresh one worker's in-flight task stamp from a [`WorkerLivenessBeat`]
1204 /// published on [`WORKER_LIVENESS_CHANNEL`].
1205 ///
1206 /// The worker is resolved by the publishing CONNECTION (`pid` -> the
1207 /// registration guard this notifier owns), never by wire identity, so a
1208 /// beat can only refresh tasks assigned to the worker that sent it. An
1209 /// untracked task is benign (an outbox dispatch the tracker never held, or
1210 /// a beat racing its completion) and is dropped silently; a malformed
1211 /// payload or unregistered connection is logged and dropped — a bad beat
1212 /// must never tear down the connection callback.
1213 fn record_liveness_beat(&self, pid: u64, payload: &[u8]) {
1214 let Some(tracker) = &self.heartbeat_tracker else {
1215 return;
1216 };
1217 let beat: WorkerLivenessBeat = match serde_json::from_slice(payload) {
1218 Ok(beat) => beat,
1219 Err(error) => {
1220 tracing::warn!(%error, "liveness tap: malformed WorkerLivenessBeat payload");
1221 return;
1222 }
1223 };
1224 let worker_id = match self.guards.lock() {
1225 Ok(guards) => guards.get(&pid).and_then(WorkerRegistration::worker_id),
1226 Err(poisoned) => poisoned
1227 .into_inner()
1228 .get(&pid)
1229 .and_then(WorkerRegistration::worker_id),
1230 };
1231 let Some(worker_id) = worker_id else {
1232 tracing::warn!(
1233 connection_pid = pid,
1234 "liveness tap: beat from a connection with no registered worker"
1235 );
1236 return;
1237 };
1238 let activity_id = ActivityId::from_sequence_position(beat.ordinal);
1239 if let Err(error) = tracker.record_liveness(
1240 worker_id,
1241 &beat.workflow_id,
1242 &activity_id,
1243 std::time::Instant::now(),
1244 ) {
1245 tracing::error!(
1246 %error,
1247 connection_pid = pid,
1248 "liveness tap: heartbeat tracker refresh failed"
1249 );
1250 }
1251 }
1252
1253 /// Apply one worker's [`WorkerCapabilitiesAnnouncement`] published on
1254 /// [`WORKER_CAPABILITIES_CHANNEL`] to its registered handle.
1255 ///
1256 /// The worker is resolved by the publishing CONNECTION (`pid` -> the
1257 /// registration guard this notifier owns), never by wire identity, so an
1258 /// announcement can only ever update the worker that sent it. A malformed
1259 /// payload or unregistered connection is logged and dropped — a bad
1260 /// announcement must never tear down the connection callback.
1261 fn record_capabilities_announcement(&self, pid: u64, payload: &[u8]) {
1262 let announcement: WorkerCapabilitiesAnnouncement = match serde_json::from_slice(payload) {
1263 Ok(announcement) => announcement,
1264 Err(error) => {
1265 tracing::warn!(
1266 %error,
1267 "capabilities tap: malformed WorkerCapabilitiesAnnouncement payload"
1268 );
1269 return;
1270 }
1271 };
1272 let worker_id = match self.guards.lock() {
1273 Ok(guards) => guards.get(&pid).and_then(WorkerRegistration::worker_id),
1274 Err(poisoned) => poisoned
1275 .into_inner()
1276 .get(&pid)
1277 .and_then(WorkerRegistration::worker_id),
1278 };
1279 let Some(worker_id) = worker_id else {
1280 tracing::warn!(
1281 connection_pid = pid,
1282 "capabilities tap: announcement from a connection with no registered worker"
1283 );
1284 return;
1285 };
1286 match self
1287 .registry
1288 .set_intervention_capabilities(worker_id, &announcement.capabilities)
1289 {
1290 Ok(true) => {}
1291 Ok(false) => tracing::warn!(
1292 connection_pid = pid,
1293 worker_id = ?worker_id,
1294 "capabilities tap: announcement raced the worker's deregistration"
1295 ),
1296 Err(error) => tracing::error!(
1297 %error,
1298 connection_pid = pid,
1299 "capabilities tap: registry capability update failed"
1300 ),
1301 }
1302 }
1303}
1304
1305impl ConnectionNotifier for LiminalConnectionNotifier {
1306 fn on_worker_registered(
1307 &self,
1308 pid: u64,
1309 registration: &WireWorkerRegistration,
1310 ) -> Result<(), LiminalServerError> {
1311 // The delivery leg needs the supervisor to push to this connection. In
1312 // correct wiring it is bound before any connection is accepted; a missing
1313 // binding is a rejected registration, never a panic.
1314 let supervisor =
1315 self.supervisor
1316 .get()
1317 .ok_or_else(|| LiminalServerError::ListenerAccept {
1318 message: format!(
1319 "liminal worker registration for connection {pid} rejected: \
1320 notifier supervisor handle not yet bound"
1321 ),
1322 })?;
1323
1324 let delivery = WorkerDelivery::Liminal(LiminalWorkerDelivery::new(supervisor.clone(), pid));
1325 let node = normalize_wire_node(registration.node.as_deref());
1326 // Insert into the SAME registry, selected the SAME way, as a gRPC worker.
1327 // A registry error (poisoned lock) becomes a Rejected ack so the worker
1328 // never believes it is registered when it is not.
1329 let guard = self
1330 .registry
1331 .register_delivery_with_capabilities(
1332 registration.namespaces.iter().cloned(),
1333 registration.task_queue.clone(),
1334 node,
1335 registration.activity_types.iter(),
1336 delivery,
1337 self.intervention_capabilities.clone(),
1338 )
1339 .map_err(|error| LiminalServerError::ListenerAccept {
1340 message: format!(
1341 "liminal worker registration for connection {pid} rejected: {error}"
1342 ),
1343 })?;
1344
1345 // OWN the guard for the connection's lifetime, keyed by pid. Dropping it
1346 // (on unregister) deregisters the worker, so the registration lives
1347 // exactly as long as the connection.
1348 let mut guards = self.guards.lock().map_err(|_| {
1349 // The accepted registry entry cannot be tracked for deregistration, so
1350 // reject (and drop the just-created guard, deregistering it) rather
1351 // than leak a never-deregistered association.
1352 LiminalServerError::ListenerAccept {
1353 message: format!(
1354 "liminal worker registration for connection {pid} rejected: \
1355 notifier guard map poisoned"
1356 ),
1357 }
1358 })?;
1359 guards.insert(pid, guard);
1360 tracing::info!(
1361 connection_pid = pid,
1362 identity = %registration.identity,
1363 task_queue = %registration.task_queue,
1364 "registered liminal worker in-band"
1365 );
1366 Ok(())
1367 }
1368
1369 fn on_worker_unregistered(&self, pid: u64) {
1370 // Remove + drop the guard for pid, deregistering the worker. A poisoned
1371 // lock on the close path has no peer to report to; recover the guard map
1372 // and still drop the guard so the registry does not keep routing to a
1373 // gone connection.
1374 let removed = match self.guards.lock() {
1375 Ok(mut guards) => guards.remove(&pid),
1376 Err(poisoned) => poisoned.into_inner().remove(&pid),
1377 };
1378 if removed.is_some() {
1379 tracing::info!(
1380 connection_pid = pid,
1381 "deregistered liminal worker on disconnect"
1382 );
1383 }
1384 }
1385
1386 fn on_channel_publish(&self, pid: u64, channel: &str, payload: &[u8]) -> bool {
1387 // Reserved liveness channel: refresh the beat's in-flight task stamp in
1388 // the shared heartbeat tracker (always consumed, never fanned out).
1389 if channel == WORKER_LIVENESS_CHANNEL {
1390 self.record_liveness_beat(pid, payload);
1391 return true;
1392 }
1393 // Reserved capabilities channel: apply the worker's advertised
1394 // intervention capabilities to its registered handle (always consumed).
1395 if channel == WORKER_CAPABILITIES_CHANNEL {
1396 self.record_capabilities_announcement(pid, payload);
1397 return true;
1398 }
1399 // Only consume the reserved observability channel; any other channel falls
1400 // through to liminal's normal fan-out (this returns false).
1401 if channel != liminal_sdk::OBSERVABILITY_CHANNEL {
1402 return false;
1403 }
1404 let Some(tap) = &self.transcript else {
1405 // No transcript sequencer installed (a non-agent deployment): still
1406 // CONSUME the reserved channel so it never leaks into the fan-out, but
1407 // drop the event — there is nothing to persist it into.
1408 return true;
1409 };
1410 let event: aion_core::ActivityEvent = match serde_json::from_slice(payload) {
1411 Ok(event) => event,
1412 Err(error) => {
1413 tracing::warn!(%error, "observability tap: malformed ActivityEvent payload");
1414 return true;
1415 }
1416 };
1417 // Bridge the synchronous connection-process callback onto the async
1418 // append+fan-out. The commit-allocated store_seq loop lives in `publish`;
1419 // a failed persist is logged, never retried (best-effort live streaming).
1420 let publisher = tap.publisher.clone();
1421 tap.runtime.spawn(async move {
1422 if let Err(error) = publisher.publish(&event).await {
1423 tracing::warn!(%error, "observability tap: transcript publish failed");
1424 }
1425 });
1426 true
1427 }
1428}
1429
1430/// The production [`InterventionTransport`](super::intervention::InterventionTransport):
1431/// pushes a routed command to the owning worker over its liminal server-push
1432/// connection (NOI-6, §6.2).
1433///
1434/// It reads the worker handle's [`WorkerDelivery::Liminal`] leg and pushes the
1435/// neutral [`InterventionRequest`] via [`LiminalWorkerDelivery::push_intervention`],
1436/// running the blocking push off the async runtime. A worker delivered over gRPC
1437/// (no liminal leg) surfaces the stale-target no-op via a connection-lost error, so
1438/// the router NACKs the operator rather than routing to a leg it cannot reach.
1439#[derive(Clone, Debug, Default)]
1440pub struct LiminalInterventionTransport;
1441
1442#[async_trait]
1443impl super::intervention::InterventionTransport for LiminalInterventionTransport {
1444 async fn push(
1445 &self,
1446 worker: &super::registry::WorkerHandle,
1447 command: aion_core::InterventionCommand,
1448 ) -> Result<aion_core::InterventionOutcome, ServerError> {
1449 let delivery = match worker.delivery() {
1450 WorkerDelivery::Liminal(delivery) => delivery.clone(),
1451 WorkerDelivery::Grpc(_) => {
1452 // No liminal leg to push to: the intervention transport rides the
1453 // liminal push channel only, so this is unreachable for the target.
1454 return Err(ServerError::worker_connection_lost(
1455 "liminal-push",
1456 "owning worker is not delivered over liminal".to_owned(),
1457 ));
1458 }
1459 };
1460 let request = InterventionRequest {
1461 intervention: command,
1462 };
1463 let reply = tokio::task::spawn_blocking(move || delivery.push_intervention(&request))
1464 .await
1465 .map_err(|error| {
1466 dispatch_error(
1467 "liminal-push",
1468 format!("intervention task join failed: {error}"),
1469 )
1470 })??;
1471 Ok(reply.outcome)
1472 }
1473}
1474
1475#[cfg(test)]
1476mod tests {
1477 use super::{channel_for_row, dispatch_channel_name, normalize_wire_node};
1478 use aion_core::{ActivityId, ContentType, Payload, WorkflowId};
1479 use aion_store::{OutboxRow, OutboxStatus};
1480 use chrono::Utc;
1481 use uuid::Uuid;
1482
1483 /// The NOI-6 dispatch owner guard RELEASES its binding on drop, on EVERY exit path
1484 /// (reply, error, panic) — so the attempt-owner back-index tracks exactly the
1485 /// attempts currently in flight. This is the invariant the dispatch path relies on
1486 /// to never leak a finished attempt's owner.
1487 #[tokio::test]
1488 async fn attempt_owner_guard_releases_on_drop() -> Result<(), Box<dyn std::error::Error>> {
1489 use super::super::intervention::{AttemptKey, AttemptOwnerIndex};
1490 use super::super::registry::{ConnectedWorkerRegistry, WorkerDelivery};
1491 use super::AttemptOwnerGuard;
1492
1493 // A real registration yields a real WorkerId (there is no fabricated id).
1494 let registry = ConnectedWorkerRegistry::default();
1495 let (tx, _rx) = tokio::sync::mpsc::channel(1);
1496 let types = [String::from("agent")];
1497 let registration = registry.register_delivery_with_capabilities(
1498 [String::from("default")],
1499 String::from("default"),
1500 None,
1501 types.iter(),
1502 WorkerDelivery::Grpc(tx),
1503 aion_core::InterventionCapabilities::none(),
1504 )?;
1505 let worker = registration
1506 .worker_id()
1507 .ok_or("registration must assign a worker id")?;
1508
1509 let owners = AttemptOwnerIndex::new();
1510 let key = AttemptKey::new(
1511 WorkflowId::new(Uuid::nil()),
1512 ActivityId::from_sequence_position(3),
1513 1,
1514 );
1515 owners.bind(key.clone(), worker);
1516 assert_eq!(
1517 owners.owner(&key),
1518 Some(worker),
1519 "owner bound before the guard"
1520 );
1521 {
1522 let _guard = AttemptOwnerGuard {
1523 owners: owners.clone(),
1524 key: key.clone(),
1525 };
1526 assert_eq!(
1527 owners.owner(&key),
1528 Some(worker),
1529 "still bound while in flight"
1530 );
1531 }
1532 // The guard dropped at the end of the block: the binding is released, so a
1533 // later intervention resolves no owner (the too-late no-op).
1534 assert_eq!(
1535 owners.owner(&key),
1536 None,
1537 "owner released when the dispatch returns"
1538 );
1539 Ok(())
1540 }
1541
1542 /// The channel format is pinned EXACTLY: any change is a wire-compatibility
1543 /// break (the dispatcher and any worker subscription must agree byte-for-byte).
1544 /// The UNPINNED (`None`) channel MUST stay byte-identical to the pre-NODE-5
1545 /// format so existing pool subscriptions are stable.
1546 #[test]
1547 fn channel_format_is_pinned() {
1548 assert_eq!(
1549 dispatch_channel_name("remote", "gpu", None),
1550 "aion.dispatch.remote.gpu"
1551 );
1552 assert_eq!(
1553 dispatch_channel_name("local", "norn", None),
1554 "aion.dispatch.local.norn"
1555 );
1556 }
1557
1558 /// A node-pinned dispatch appends the node as an injectively-encoded
1559 /// sub-segment: `f(ns, tq, Some(node))` == `aion.dispatch.{ns}.{tq}.{node}`.
1560 #[test]
1561 fn node_pinned_channel_appends_node_subsegment() {
1562 assert_eq!(
1563 dispatch_channel_name("remote", "gpu", Some("box-7")),
1564 "aion.dispatch.remote.gpu.box-7"
1565 );
1566 }
1567
1568 /// Same input always yields the same channel (the function is stable/total),
1569 /// for both the unpinned and node-pinned cases.
1570 #[test]
1571 fn channel_derivation_is_stable() {
1572 assert_eq!(
1573 dispatch_channel_name("default", "default", None),
1574 dispatch_channel_name("default", "default", None)
1575 );
1576 assert_eq!(
1577 dispatch_channel_name("default", "default", Some("box-1")),
1578 dispatch_channel_name("default", "default", Some("box-1"))
1579 );
1580 }
1581
1582 /// Distinct `(namespace, task_queue)` pools derive distinct channels — the
1583 /// whole point of NSTQ-5: `(remote, gpu)` and `(local, norn)` never collide.
1584 #[test]
1585 fn distinct_pools_get_distinct_channels() {
1586 assert_ne!(
1587 dispatch_channel_name("remote", "gpu", None),
1588 dispatch_channel_name("local", "norn", None)
1589 );
1590 }
1591
1592 /// A node-pinned dispatch and the unpinned dispatch for the SAME pool derive
1593 /// DISTINCT channels, and two distinct nodes for the same pool also differ —
1594 /// the property node isolation rests on (the subscriber contract).
1595 #[test]
1596 fn node_pin_separates_channels() {
1597 let unpinned = dispatch_channel_name("remote", "gpu", None);
1598 let box7 = dispatch_channel_name("remote", "gpu", Some("box-7"));
1599 let box8 = dispatch_channel_name("remote", "gpu", Some("box-8"));
1600 assert_ne!(
1601 unpinned, box7,
1602 "pinned dispatch must not reach unpinned pool"
1603 );
1604 assert_ne!(box7, box8, "distinct nodes must not collide");
1605 }
1606
1607 /// The core injectivity property: free-form fields containing the segment
1608 /// separator `.` must NOT bleed across the join. With the raw `format!` the
1609 /// disjoint pools `("a.b", "c")` and `("a", "b.c")` both collapsed onto
1610 /// `aion.dispatch.a.b.c` — a cross-pool/cross-namespace leak. The per-segment
1611 /// encode keeps them distinct.
1612 #[test]
1613 fn dotted_fields_do_not_collide_across_segments() {
1614 assert_ne!(
1615 dispatch_channel_name("a.b", "c", None),
1616 dispatch_channel_name("a", "b.c", None),
1617 "a '.' in a field must not bleed across the segment separator"
1618 );
1619 }
1620
1621 /// Injectivity holds ACROSS segment counts: a 2-segment (unpinned) channel
1622 /// can never be confused with a 3-segment (node-pinned) channel even when a
1623 /// `.` in a field would otherwise make the raw strings line up. Both
1624 /// directions of the brief's collision cases must stay distinct.
1625 #[test]
1626 fn node_subsegment_does_not_collide_with_dotted_fields() {
1627 // A node sub-segment vs the same dot living inside task_queue.
1628 assert_ne!(
1629 dispatch_channel_name("a", "b", Some("c")),
1630 dispatch_channel_name("a", "b.c", None),
1631 "a node sub-segment must not collide with a dotted task_queue"
1632 );
1633 // The dot living inside namespace vs a node sub-segment.
1634 assert_ne!(
1635 dispatch_channel_name("a.b", "c", None),
1636 dispatch_channel_name("a", "b", Some("c")),
1637 "a dotted namespace must not collide with a node-pinned channel"
1638 );
1639 }
1640
1641 /// More reserved-char shifts that the raw `format!` collapsed but the encode
1642 /// must keep distinct — the dot can sit on either side of the boundary.
1643 #[test]
1644 fn reserved_char_shifts_stay_distinct() {
1645 // Dot at the end of namespace vs start of task_queue.
1646 assert_ne!(
1647 dispatch_channel_name("ns.", "tq", None),
1648 dispatch_channel_name("ns", ".tq", None)
1649 );
1650 // Empty field vs the dot living in the other field.
1651 assert_ne!(
1652 dispatch_channel_name("", "a.b", None),
1653 dispatch_channel_name(".a", "b", None)
1654 );
1655 // The escape char itself must not let a literal `%2E` impersonate an
1656 // encoded `.`: `("%2E", "x")` (literal percent-two-E) must differ from
1657 // `(".", "x")` (an actual dot, which encodes to `%2E`).
1658 assert_ne!(
1659 dispatch_channel_name("%2E", "x", None),
1660 dispatch_channel_name(".", "x", None)
1661 );
1662 }
1663
1664 /// Encoding is injective in ALL THREE segments independently and is exactly
1665 /// reversible (the property the channel relies on), so a small exhaustive
1666 /// sweep of reserved-char arrangements — INCLUDING the optional node taking
1667 /// `None` and every reserved-char value — yields all-distinct channels. This
1668 /// covers cross-segment-count collisions (the `None` vs `Some` boundary) too.
1669 #[test]
1670 fn encoding_is_injective_over_reserved_char_triples() {
1671 let fields = ["a", "a.b", "a.", ".a", ".", "", "%", "%2E", "a%b", "%2."];
1672 let nodes = [
1673 None,
1674 Some("a"),
1675 Some("a.b"),
1676 Some("."),
1677 Some(""),
1678 Some("%2E"),
1679 ];
1680 let mut channels = std::collections::HashSet::new();
1681 for ns in fields {
1682 for tq in fields {
1683 for node in nodes {
1684 let channel = dispatch_channel_name(ns, tq, node);
1685 assert!(
1686 channels.insert(channel.clone()),
1687 "collision on ({ns:?}, {tq:?}, {node:?}) -> {channel}"
1688 );
1689 }
1690 }
1691 }
1692 }
1693
1694 fn row(namespace: &str, task_queue: &str) -> OutboxRow {
1695 let workflow_id = WorkflowId::new(Uuid::new_v4());
1696 OutboxRow {
1697 dispatch_key: format!("{workflow_id}:0"),
1698 workflow_id,
1699 ordinal: 0,
1700 run_id: None,
1701 namespace: namespace.to_owned(),
1702 task_queue: task_queue.to_owned(),
1703 node: None,
1704 activity_type: "charge-card".to_owned(),
1705 input: Payload::new(ContentType::Json, Vec::new()),
1706 status: OutboxStatus::Pending,
1707 attempt: 0,
1708 visible_after: Utc::now(),
1709 claimed_at: None,
1710 }
1711 }
1712
1713 /// A row's channel is derived from its durable `(namespace, task_queue)`
1714 /// columns (NSTQ-2), through the same single derivation function — and
1715 /// `activity_type` does NOT enter the channel. With `node = None` the channel
1716 /// is byte-identical to the pre-NODE-5 2-segment form.
1717 #[test]
1718 fn channel_for_row_uses_namespace_and_task_queue_only() {
1719 let remote_gpu = row("remote", "gpu");
1720 let local_norn = row("local", "norn");
1721 assert_eq!(channel_for_row(&remote_gpu), "aion.dispatch.remote.gpu");
1722 assert_eq!(channel_for_row(&local_norn), "aion.dispatch.local.norn");
1723 assert_ne!(channel_for_row(&remote_gpu), channel_for_row(&local_norn));
1724
1725 // Two rows that differ ONLY in activity_type derive the SAME channel:
1726 // activity_type is matched after delivery, not used to select the pool.
1727 let mut other_activity = row("remote", "gpu");
1728 other_activity.activity_type = "refund".to_owned();
1729 assert_eq!(
1730 channel_for_row(&remote_gpu),
1731 channel_for_row(&other_activity),
1732 "activity_type must not affect the channel"
1733 );
1734 }
1735
1736 /// A row carrying `Some(node)` (NODE-2) derives the node-pinned sub-channel,
1737 /// distinct from the same pool's unpinned channel; a row with `None` derives
1738 /// the 2-segment channel. `channel_for_row` threads `row.node` through the
1739 /// single derivation function.
1740 #[test]
1741 fn channel_for_row_derives_node_subchannel_when_pinned() {
1742 let mut pinned = row("remote", "gpu");
1743 pinned.node = Some("box-7".to_owned());
1744 assert_eq!(channel_for_row(&pinned), "aion.dispatch.remote.gpu.box-7");
1745
1746 let unpinned = row("remote", "gpu");
1747 assert_eq!(channel_for_row(&unpinned), "aion.dispatch.remote.gpu");
1748 assert_ne!(channel_for_row(&pinned), channel_for_row(&unpinned));
1749 }
1750
1751 /// The outbox wire request stamps the row's stored ZERO-based attempt as a
1752 /// ONE-based delivery attempt (zero is malformed on the wire) — the exact
1753 /// stamp the gRPC outbox arm's `to_scheduled` applies — carries no labels,
1754 /// and assigns no liveness window (outbox rows are not tracker-tracked; the
1755 /// outbox retry loop is their liveness backstop).
1756 #[test]
1757 fn request_for_row_stamps_one_based_attempt_and_no_window() {
1758 let mut retried = row("remote", "gpu");
1759 retried.attempt = 2;
1760 let request = super::request_for_row(&retried);
1761 assert_eq!(
1762 request.attempt, 3,
1763 "zero-based row attempt goes one-based on the wire"
1764 );
1765 assert!(request.labels.is_empty());
1766 assert_eq!(request.heartbeat_window_ms, 0);
1767
1768 let fresh = row("remote", "gpu");
1769 assert_eq!(super::request_for_row(&fresh).attempt, 1);
1770 }
1771
1772 /// The wire `node` is normalized onto the registry's optional affinity with
1773 /// the SAME none-convention the gRPC registration path uses: `None` and the
1774 /// empty-string node both collapse to unpinned (`None`), a non-empty value is
1775 /// the advertised node. An empty-string node must NOT register a distinct
1776 /// empty affinity no pinned dispatch could match.
1777 #[test]
1778 fn wire_node_normalizes_empty_to_none() {
1779 assert_eq!(normalize_wire_node(None), None);
1780 assert_eq!(normalize_wire_node(Some("")), None);
1781 assert_eq!(normalize_wire_node(Some("box-7")), Some("box-7".to_owned()));
1782 }
1783
1784 // --- #163: the Prefer two-tier spill on the LIMINAL selection path ---------
1785 //
1786 // These exercise `RegistryLiminalDispatch::select_liminal_worker` — the
1787 // liminal transport's worker selection — proving it consults the SAME shared
1788 // `preferred_node_order` two-tier spill the gRPC path uses (the cross-node
1789 // demo behaviour), and that placement NEVER mutates the recorded row's node.
1790 // Selection is delivery-agnostic (`select_worker` filters by node regardless
1791 // of transport), so a worker registered with any delivery drives the same
1792 // selection the production liminal-delivered worker would; the tests assert on
1793 // the SELECTED handle's node, which is exactly what #163 changed.
1794 mod placement_selection {
1795 use std::collections::BTreeSet;
1796 use std::sync::Arc;
1797 use std::time::Duration;
1798
1799 use aion_core::{ActivityId, Payload, RunId, WorkflowId};
1800 use aion_store::{
1801 InMemoryStore, NamespaceOrigin, NamespacePlacement, NamespaceStore, OutboxRow,
1802 };
1803
1804 use crate::error::ServerError;
1805 use crate::worker::PlacementCache;
1806 use crate::worker::bridge::OutboxDeliveryCallback;
1807 use crate::worker::registry::{ConnectedWorkerRegistry, WorkerMessage, WorkerRegistration};
1808
1809 use super::super::RegistryLiminalDispatch;
1810
1811 /// No-op delivery callback: the selection tests never deliver a result, so
1812 /// the completion sink is never invoked. Both methods are unreachable in
1813 /// these tests and simply report "no live run" if ever called.
1814 struct NoopCallback;
1815
1816 impl OutboxDeliveryCallback for NoopCallback {
1817 fn deliver_completion(
1818 &self,
1819 _workflow_id: &WorkflowId,
1820 _activity_id: &ActivityId,
1821 _run_id: Option<&RunId>,
1822 _result: String,
1823 ) -> Result<bool, ServerError> {
1824 Ok(false)
1825 }
1826 fn deliver_failure(
1827 &self,
1828 _workflow_id: &WorkflowId,
1829 _activity_id: &ActivityId,
1830 _run_id: Option<&RunId>,
1831 _reason: String,
1832 ) -> Result<bool, ServerError> {
1833 Ok(false)
1834 }
1835 }
1836
1837 fn labels(values: &[&str]) -> BTreeSet<String> {
1838 values.iter().map(|v| (*v).to_owned()).collect()
1839 }
1840
1841 /// Register a worker advertising `node` for `charge` in `namespace`,
1842 /// returning the registration guard (held to keep it connected).
1843 fn register_node_worker(
1844 registry: &ConnectedWorkerRegistry,
1845 namespace: &str,
1846 node: &str,
1847 ) -> Result<WorkerRegistration, ServerError> {
1848 let (tx, _rx) = tokio::sync::mpsc::channel::<WorkerMessage>(1);
1849 let types = [String::from("charge")];
1850 registry.register_namespaces(
1851 [namespace.to_owned()],
1852 String::from("default"),
1853 Some(node.to_owned()),
1854 types.iter(),
1855 tx,
1856 )
1857 }
1858
1859 /// Build an UNPINNED outbox row (`node == None`) in `namespace` for `charge`.
1860 fn unpinned_row(namespace: &str) -> OutboxRow {
1861 OutboxRow::pending(
1862 WorkflowId::new_v4(),
1863 0,
1864 String::from("charge"),
1865 Payload::from_json(&serde_json::json!({}))
1866 .unwrap_or_else(|_| Payload::new(aion_core::ContentType::Json, Vec::new())),
1867 chrono::Utc::now(),
1868 )
1869 .with_namespace(namespace)
1870 .with_task_queue("default")
1871 }
1872
1873 /// A namespace store with `namespace` set to `Prefer{nodes}`.
1874 async fn prefer_store(
1875 namespace: &str,
1876 nodes: &[&str],
1877 ) -> Result<Arc<dyn NamespaceStore>, ServerError> {
1878 let store: Arc<dyn NamespaceStore> = Arc::new(InMemoryStore::default());
1879 store
1880 .register_namespace(namespace, NamespaceOrigin::Explicit)
1881 .await?;
1882 store
1883 .set_namespace_placement(
1884 namespace,
1885 NamespacePlacement::Prefer {
1886 nodes: labels(nodes),
1887 },
1888 )
1889 .await?;
1890 Ok(store)
1891 }
1892
1893 /// A namespace store with `namespace` set to `Pinned{nodes}` (P2-I1).
1894 async fn pinned_store(
1895 namespace: &str,
1896 nodes: &[&str],
1897 ) -> Result<Arc<dyn NamespaceStore>, ServerError> {
1898 let store: Arc<dyn NamespaceStore> = Arc::new(InMemoryStore::default());
1899 store
1900 .register_namespace(namespace, NamespaceOrigin::Explicit)
1901 .await?;
1902 store
1903 .set_namespace_placement(
1904 namespace,
1905 NamespacePlacement::Pinned {
1906 nodes: labels(nodes),
1907 },
1908 )
1909 .await?;
1910 Ok(store)
1911 }
1912
1913 /// Build a `RegistryLiminalDispatch` over `registry` whose placement cache
1914 /// reads `ns_store` (zero TTL so each selection sees the latest placement).
1915 fn liminal_dispatch(
1916 registry: &ConnectedWorkerRegistry,
1917 ns_store: Arc<dyn NamespaceStore>,
1918 ) -> RegistryLiminalDispatch {
1919 let cache = PlacementCache::new(ns_store, Duration::ZERO);
1920 RegistryLiminalDispatch::new(registry.clone(), Arc::new(NoopCallback))
1921 .with_placement_cache(cache)
1922 }
1923
1924 /// #163 (prefer): an unpinned row in a `Prefer{n1}` namespace selects the
1925 /// n1 worker on the liminal path when one is live, even with an n2 worker
1926 /// also connected.
1927 #[tokio::test]
1928 async fn prefer_selects_preferred_node_worker_on_liminal_path()
1929 -> Result<(), Box<dyn std::error::Error>> {
1930 let ns_store = prefer_store("t", &["n1"]).await?;
1931 let registry = ConnectedWorkerRegistry::default();
1932 let _n1 = register_node_worker(®istry, "t", "n1")?;
1933 let _n2 = register_node_worker(®istry, "t", "n2")?;
1934 let dispatch = liminal_dispatch(®istry, Arc::clone(&ns_store));
1935
1936 let row = unpinned_row("t");
1937 let selected = dispatch
1938 .select_liminal_worker(&row)
1939 .await?
1940 .ok_or("a worker must be selected")?;
1941 assert_eq!(
1942 selected.node(),
1943 Some("n1"),
1944 "the liminal path prefers the n1 worker while it is live"
1945 );
1946 // Determinism gate: preference never mutates the recorded row's node.
1947 assert_eq!(row.node, None, "placement must never mutate the row's node");
1948 Ok(())
1949 }
1950
1951 /// #163 (spill): an unpinned row in a `Prefer{n1}` namespace SPILLS to the
1952 /// only live worker (n2) on the liminal path when no n1 worker is
1953 /// connected — the cross-node node-loss failover behaviour.
1954 #[tokio::test]
1955 async fn prefer_spills_to_any_live_worker_on_liminal_path()
1956 -> Result<(), Box<dyn std::error::Error>> {
1957 let ns_store = prefer_store("t", &["n1"]).await?;
1958 // Only an n2 worker is live: no n1-labelled worker exists at all.
1959 let registry = ConnectedWorkerRegistry::default();
1960 let _n2 = register_node_worker(®istry, "t", "n2")?;
1961 let dispatch = liminal_dispatch(®istry, Arc::clone(&ns_store));
1962
1963 let row = unpinned_row("t");
1964 let selected = dispatch
1965 .select_liminal_worker(&row)
1966 .await?
1967 .ok_or("the spill must select the live n2 worker")?;
1968 assert_eq!(
1969 selected.node(),
1970 Some("n2"),
1971 "with no n1 worker live, the liminal selection spills to the live n2 worker"
1972 );
1973 assert_eq!(row.node, None, "spill must never mutate the row's node");
1974 Ok(())
1975 }
1976
1977 /// #163 (determinism, mirrors the gRPC `placement_never_mutates_recorded_row_node`
1978 /// test): under `Prefer{n1}` the SAME unpinned row selected once to the n1
1979 /// worker and once (after n1 leaves) spilled to n2 keeps `node == None`
1980 /// BOTH times — selection reads the row's node, never the placement, so
1981 /// replay sees an identical command stream irrespective of the target.
1982 #[tokio::test]
1983 async fn placement_never_mutates_recorded_row_node_on_liminal_path()
1984 -> Result<(), Box<dyn std::error::Error>> {
1985 let ns_store = prefer_store("t", &["n1"]).await?;
1986 let registry = ConnectedWorkerRegistry::default();
1987 let dispatch = liminal_dispatch(®istry, Arc::clone(&ns_store));
1988
1989 // Routing A: n1 present -> preferred selection.
1990 let n1 = register_node_worker(®istry, "t", "n1")?;
1991 let row_a = unpinned_row("t");
1992 let selected_a = dispatch
1993 .select_liminal_worker(&row_a)
1994 .await?
1995 .ok_or("routing A must select a worker")?;
1996 assert_eq!(selected_a.node(), Some("n1"));
1997
1998 // n1 leaves; only n2 remains.
1999 n1.deregister()?;
2000 let _n2 = register_node_worker(®istry, "t", "n2")?;
2001
2002 // Routing B: same shape of unpinned row -> spills to n2.
2003 let row_b = unpinned_row("t");
2004 let selected_b = dispatch
2005 .select_liminal_worker(&row_b)
2006 .await?
2007 .ok_or("routing B must spill to a worker")?;
2008 assert_eq!(selected_b.node(), Some("n2"));
2009
2010 // The recorded row node is None in BOTH routings: the dispatch target
2011 // (n1 vs n2) did not perturb it.
2012 assert_eq!(row_a.node, None);
2013 assert_eq!(row_b.node, None);
2014 assert_eq!(
2015 row_a.node, row_b.node,
2016 "the recorded row node is identical regardless of which worker was selected"
2017 );
2018 Ok(())
2019 }
2020
2021 /// #164 (P2-I1 hard pin): an unpinned row in a `Pinned{n1}` namespace
2022 /// selects the n1 worker on the liminal path when live — exactly like
2023 /// Prefer's happy path.
2024 #[tokio::test]
2025 async fn pinned_selects_required_node_worker_on_liminal_path()
2026 -> Result<(), Box<dyn std::error::Error>> {
2027 let ns_store = pinned_store("t", &["n1"]).await?;
2028 let registry = ConnectedWorkerRegistry::default();
2029 let _n1 = register_node_worker(®istry, "t", "n1")?;
2030 let _n2 = register_node_worker(®istry, "t", "n2")?;
2031 let dispatch = liminal_dispatch(®istry, Arc::clone(&ns_store));
2032
2033 let row = unpinned_row("t");
2034 let selected = dispatch
2035 .select_liminal_worker(&row)
2036 .await?
2037 .ok_or("the required n1 worker must be selected")?;
2038 assert_eq!(selected.node(), Some("n1"));
2039 assert_eq!(row.node, None, "placement must never mutate the row's node");
2040 Ok(())
2041 }
2042
2043 /// #164 (P2-I1 no spill — the load-bearing test): an unpinned row in a
2044 /// `Pinned{n1}` namespace with ONLY a live n2 worker selects NOTHING — it
2045 /// must NEVER spill to the wrong-node worker. This is the exact opposite of
2046 /// the `prefer_spills_to_any_live_worker_on_liminal_path` behaviour and would
2047 /// FAIL under the old fall-through (which selected any worker for Pinned).
2048 /// The `Ok(None)` drives the outbox no-worker retry/stall, mirroring the
2049 /// gRPC wait.
2050 #[tokio::test]
2051 async fn pinned_never_spills_to_a_wrong_node_worker_on_liminal_path()
2052 -> Result<(), Box<dyn std::error::Error>> {
2053 let ns_store = pinned_store("t", &["n1"]).await?;
2054 // Only an n2 worker is live: no n1-labelled worker exists at all.
2055 let registry = ConnectedWorkerRegistry::default();
2056 let _n2 = register_node_worker(®istry, "t", "n2")?;
2057 let dispatch = liminal_dispatch(®istry, Arc::clone(&ns_store));
2058
2059 let row = unpinned_row("t");
2060 let selected = dispatch.select_liminal_worker(&row).await?;
2061 assert!(
2062 selected.is_none(),
2063 "Pinned{{n1}} must NOT spill to the live n2 worker — it selects nothing \
2064 so the outbox retries/stalls until an n1 worker returns"
2065 );
2066 assert_eq!(row.node, None, "placement must never mutate the row's node");
2067 Ok(())
2068 }
2069
2070 /// #163 (authored pin wins): a row authored-pinned to `Some(n2)` STILL
2071 /// selects an n2 worker on the liminal path regardless of the namespace's
2072 /// `Prefer{n1}` — the per-activity pin is authoritative and placement never
2073 /// overrides it.
2074 #[tokio::test]
2075 async fn authored_node_pin_wins_over_namespace_prefer_on_liminal_path()
2076 -> Result<(), Box<dyn std::error::Error>> {
2077 let ns_store = prefer_store("t", &["n1"]).await?;
2078 let registry = ConnectedWorkerRegistry::default();
2079 let _n1 = register_node_worker(®istry, "t", "n1")?;
2080 let _n2 = register_node_worker(®istry, "t", "n2")?;
2081 let dispatch = liminal_dispatch(®istry, Arc::clone(&ns_store));
2082
2083 // Authored pin: node = Some("n2").
2084 let row = unpinned_row("t").with_node(Some(String::from("n2")));
2085 let selected = dispatch
2086 .select_liminal_worker(&row)
2087 .await?
2088 .ok_or("the authored pin must select the n2 worker")?;
2089 assert_eq!(
2090 selected.node(),
2091 Some("n2"),
2092 "the authored Some(n2) pin is honoured regardless of the namespace Prefer{{n1}}"
2093 );
2094 // The authored node is preserved exactly (determinism gate).
2095 assert_eq!(row.node.as_deref(), Some("n2"));
2096 Ok(())
2097 }
2098
2099 /// #163 (byte-identical default): an `Unplaced` namespace selects any live
2100 /// worker on the liminal path exactly as the pre-Phase-2 single
2101 /// `select_worker` would — the ceiling/placement never engages.
2102 #[tokio::test]
2103 async fn unplaced_namespace_selects_any_worker_on_liminal_path()
2104 -> Result<(), Box<dyn std::error::Error>> {
2105 let ns_store: Arc<dyn NamespaceStore> = Arc::new(InMemoryStore::default());
2106 // Registered but left Unplaced (the default placement).
2107 ns_store
2108 .register_namespace("t", NamespaceOrigin::Explicit)
2109 .await?;
2110 let registry = ConnectedWorkerRegistry::default();
2111 let _n2 = register_node_worker(®istry, "t", "n2")?;
2112 let dispatch = liminal_dispatch(®istry, Arc::clone(&ns_store));
2113
2114 let selected = dispatch
2115 .select_liminal_worker(&unpinned_row("t"))
2116 .await?
2117 .ok_or("an Unplaced namespace still selects a live worker")?;
2118 assert_eq!(
2119 selected.node(),
2120 Some("n2"),
2121 "an Unplaced namespace reaches any live worker, exactly as before"
2122 );
2123 Ok(())
2124 }
2125
2126 /// #163 (byte-identical, no cache): with NO placement cache attached, the
2127 /// liminal selection is the single `select_worker` off the row's own node —
2128 /// byte-identical to the pre-#163 construction. An unpinned row reaches any
2129 /// live worker; the namespace's `Prefer` is not even consulted.
2130 #[tokio::test]
2131 async fn no_cache_selection_is_byte_identical_to_pre_163()
2132 -> Result<(), Box<dyn std::error::Error>> {
2133 // The namespace prefers n1, but with no cache the preference is ignored.
2134 let _ns_store = prefer_store("t", &["n1"]).await?;
2135 let registry = ConnectedWorkerRegistry::default();
2136 let _n2 = register_node_worker(®istry, "t", "n2")?;
2137 // No `.with_placement_cache(...)`: the pre-#163 construction.
2138 let dispatch = RegistryLiminalDispatch::new(registry.clone(), Arc::new(NoopCallback));
2139
2140 let selected = dispatch
2141 .select_liminal_worker(&unpinned_row("t"))
2142 .await?
2143 .ok_or("without a cache the unpinned row still selects any worker")?;
2144 assert_eq!(
2145 selected.node(),
2146 Some("n2"),
2147 "with no placement cache the selection is the unchanged any-worker path"
2148 );
2149 Ok(())
2150 }
2151 }
2152}