Skip to main content

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(&registry, "t", "n1")?;
1933            let _n2 = register_node_worker(&registry, "t", "n2")?;
1934            let dispatch = liminal_dispatch(&registry, 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(&registry, "t", "n2")?;
1961            let dispatch = liminal_dispatch(&registry, 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(&registry, Arc::clone(&ns_store));
1988
1989            // Routing A: n1 present -> preferred selection.
1990            let n1 = register_node_worker(&registry, "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(&registry, "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(&registry, "t", "n1")?;
2030            let _n2 = register_node_worker(&registry, "t", "n2")?;
2031            let dispatch = liminal_dispatch(&registry, 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(&registry, "t", "n2")?;
2057            let dispatch = liminal_dispatch(&registry, 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(&registry, "t", "n1")?;
2080            let _n2 = register_node_worker(&registry, "t", "n2")?;
2081            let dispatch = liminal_dispatch(&registry, 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(&registry, "t", "n2")?;
2112            let dispatch = liminal_dispatch(&registry, 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(&registry, "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}